-
-
Notifications
You must be signed in to change notification settings - Fork 37.2k
Add onboarding support #15492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add onboarding support #15492
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| """Component to help onboard new users.""" | ||
| from homeassistant.core import callback | ||
| from homeassistant.loader import bind_hass | ||
|
|
||
| from .const import STEPS, STEP_USER, DOMAIN | ||
|
|
||
| DEPENDENCIES = ['http'] | ||
| STORAGE_KEY = DOMAIN | ||
| STORAGE_VERSION = 1 | ||
|
|
||
|
|
||
| @bind_hass | ||
| @callback | ||
| def async_is_onboarded(hass): | ||
| """Return if Home Assistant has been onboarded.""" | ||
| # Temporarily: if auth not active, always set onboarded=True | ||
| if not hass.auth.active: | ||
| return True | ||
|
|
||
| return hass.data.get(DOMAIN, True) | ||
|
|
||
|
|
||
| async def async_setup(hass, config): | ||
| """Set up the onboard component.""" | ||
| store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) | ||
| data = await store.async_load() | ||
|
|
||
| if data is None: | ||
| data = { | ||
| 'done': [] | ||
| } | ||
|
|
||
| if STEP_USER not in data['done']: | ||
| # Users can already have created an owner account via the command line | ||
| # If so, mark the user step as done. | ||
| has_owner = False | ||
|
|
||
| for user in await hass.auth.async_get_users(): | ||
| if user.is_owner: | ||
| has_owner = True | ||
| break | ||
|
|
||
| if has_owner: | ||
| data['done'].append(STEP_USER) | ||
| await store.async_save(data) | ||
|
|
||
| if set(data['done']) == set(STEPS): | ||
| return True | ||
|
|
||
| hass.data[DOMAIN] = False | ||
|
|
||
| from . import views | ||
|
|
||
| await views.async_setup(hass, data, store) | ||
|
|
||
| return True | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| """Constants for the onboarding component.""" | ||
| DOMAIN = 'onboarding' | ||
| STEP_USER = 'user' | ||
|
|
||
| STEPS = [ | ||
| STEP_USER | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| """Onboarding views.""" | ||
| import asyncio | ||
|
|
||
| import voluptuous as vol | ||
|
|
||
| from homeassistant.core import callback | ||
| from homeassistant.components.http.view import HomeAssistantView | ||
| from homeassistant.components.http.data_validator import RequestDataValidator | ||
|
|
||
| from .const import DOMAIN, STEPS, STEP_USER | ||
|
|
||
|
|
||
| async def async_setup(hass, data, store): | ||
| """Setup onboarding.""" | ||
| hass.http.register_view(OnboardingView(data, store)) | ||
| hass.http.register_view(UserOnboardingView(data, store)) | ||
|
|
||
|
|
||
| class OnboardingView(HomeAssistantView): | ||
| """Returns the onboarding status.""" | ||
|
|
||
| requires_auth = False | ||
| url = '/api/onboarding' | ||
| name = 'api:onboarding' | ||
|
|
||
| def __init__(self, data, store): | ||
| """Initialize the onboarding view.""" | ||
| self._store = store | ||
| self._data = data | ||
|
|
||
| async def get(self, request): | ||
| """Return the onboarding status.""" | ||
| return self.json([ | ||
| { | ||
| 'step': key, | ||
| 'done': key in self._data['done'], | ||
| } for key in STEPS | ||
| ]) | ||
|
|
||
|
|
||
| class _BaseOnboardingView(HomeAssistantView): | ||
| """Base class for onboarding.""" | ||
|
|
||
| requires_auth = False | ||
| step = None | ||
|
|
||
| def __init__(self, data, store): | ||
| """Initialize the onboarding view.""" | ||
| self._store = store | ||
| self._data = data | ||
| self._lock = asyncio.Lock() | ||
|
|
||
| @callback | ||
| def _async_is_done(self): | ||
| """Return if this step is done.""" | ||
| return self.step in self._data['done'] | ||
|
|
||
| async def _async_mark_done(self, hass): | ||
| """Mark step as done.""" | ||
| self._data['done'].append(self.step) | ||
| await self._store.async_save(self._data) | ||
|
|
||
| hass.data[DOMAIN] = len(self._data) == len(STEPS) | ||
|
|
||
|
|
||
| class UserOnboardingView(_BaseOnboardingView): | ||
| """View to handle onboarding.""" | ||
|
|
||
| url = '/api/onboarding/users' | ||
| name = 'api:onboarding:users' | ||
| step = STEP_USER | ||
|
|
||
| @RequestDataValidator(vol.Schema({ | ||
| vol.Required('name'): str, | ||
| vol.Required('username'): str, | ||
| vol.Required('password'): str, | ||
| })) | ||
| async def post(self, request, data): | ||
| """Return the manifest.json.""" | ||
| hass = request.app['hass'] | ||
|
|
||
| async with self._lock: | ||
| if self._async_is_done(): | ||
| return self.json_message('User step already done', 403) | ||
|
|
||
| provider = _async_get_hass_provider(hass) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe in the future we can allow the first user coming from other provider by adding setup_shema to the provider.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could but I don't know if I would want to . If people want to configure their own auth providers, surely they will skip onboarding?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then we need to check if owner already created in
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not a use case I am currently worried about.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am thinking the migration problem for current 'new auth' user, they have the need to skip onboarding user step.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a good point. If an owner exists I'll mark the user step as done. |
||
| await provider.async_initialize() | ||
|
|
||
| user = await hass.auth.async_create_user(data['name']) | ||
| await hass.async_add_executor_job( | ||
| provider.data.add_auth, data['username'], data['password']) | ||
| credentials = await provider.async_get_or_create_credentials({ | ||
| 'username': data['username'] | ||
| }) | ||
| await hass.auth.async_link_user(user, credentials) | ||
| await self._async_mark_done(hass) | ||
|
|
||
|
|
||
| @callback | ||
| def _async_get_hass_provider(hass): | ||
| """Get the Home Assistant auth provider.""" | ||
| for prv in hass.auth.auth_providers: | ||
| if prv.type == 'homeassistant': | ||
| return prv | ||
|
|
||
| raise RuntimeError('No Home Assistant provider found') | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| """Tests for the onboarding component.""" | ||
|
|
||
| from homeassistant.components import onboarding | ||
|
|
||
|
|
||
| def mock_storage(hass_storage, data): | ||
| """Mock the onboarding storage.""" | ||
| hass_storage[onboarding.STORAGE_KEY] = { | ||
| 'version': onboarding.STORAGE_VERSION, | ||
| 'data': data | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| """Tests for the init.""" | ||
| from unittest.mock import patch, Mock | ||
|
|
||
| from homeassistant.setup import async_setup_component | ||
| from homeassistant.components import onboarding | ||
|
|
||
| from tests.common import mock_coro, MockUser | ||
|
|
||
| from . import mock_storage | ||
|
|
||
| # Temporarily: if auth not active, always set onboarded=True | ||
|
|
||
|
|
||
| async def test_not_setup_views_if_onboarded(hass, hass_storage): | ||
| """Test if onboarding is done, we don't setup views.""" | ||
| mock_storage(hass_storage, { | ||
| 'done': onboarding.STEPS | ||
| }) | ||
|
|
||
| with patch( | ||
| 'homeassistant.components.onboarding.views.async_setup' | ||
| ) as mock_setup: | ||
| assert await async_setup_component(hass, 'onboarding', {}) | ||
|
|
||
| assert len(mock_setup.mock_calls) == 0 | ||
| assert onboarding.DOMAIN not in hass.data | ||
| assert onboarding.async_is_onboarded(hass) | ||
|
|
||
|
|
||
| async def test_setup_views_if_not_onboarded(hass): | ||
| """Test if onboarding is not done, we setup views.""" | ||
| with patch( | ||
| 'homeassistant.components.onboarding.views.async_setup', | ||
| return_value=mock_coro() | ||
| ) as mock_setup: | ||
| assert await async_setup_component(hass, 'onboarding', {}) | ||
|
|
||
| assert len(mock_setup.mock_calls) == 1 | ||
| assert onboarding.DOMAIN in hass.data | ||
|
|
||
| with patch('homeassistant.auth.AuthManager.active', return_value=True): | ||
| assert not onboarding.async_is_onboarded(hass) | ||
|
|
||
|
|
||
| async def test_is_onboarded(): | ||
| """Test the is onboarded function.""" | ||
| hass = Mock() | ||
| hass.data = {} | ||
|
|
||
| with patch('homeassistant.auth.AuthManager.active', return_value=False): | ||
| assert onboarding.async_is_onboarded(hass) | ||
|
|
||
| with patch('homeassistant.auth.AuthManager.active', return_value=True): | ||
| assert onboarding.async_is_onboarded(hass) | ||
|
|
||
| hass.data[onboarding.DOMAIN] = True | ||
| assert onboarding.async_is_onboarded(hass) | ||
|
|
||
| hass.data[onboarding.DOMAIN] = False | ||
| assert not onboarding.async_is_onboarded(hass) | ||
|
|
||
|
|
||
| async def test_having_owner_finishes_user_step(hass, hass_storage): | ||
| """If owner user already exists, mark user step as complete.""" | ||
| MockUser(is_owner=True).add_to_hass(hass) | ||
|
|
||
| with patch( | ||
| 'homeassistant.components.onboarding.views.async_setup' | ||
| ) as mock_setup, patch.object(onboarding, 'STEPS', [onboarding.STEP_USER]): | ||
| assert await async_setup_component(hass, 'onboarding', {}) | ||
|
|
||
| assert len(mock_setup.mock_calls) == 0 | ||
| assert onboarding.DOMAIN not in hass.data | ||
| assert onboarding.async_is_onboarded(hass) | ||
|
|
||
| done = hass_storage[onboarding.STORAGE_KEY]['data']['done'] | ||
| assert onboarding.STEP_USER in done |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should check if owner already created
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We do those checks inside the
async_setup. We're not checking explicitly for owner, but instead check if the user step is done. There can be many steps in the future.