Add onboarding support (#15492)
* Add onboarding support * Lint * Address comments * Mark user step as done if owner user already created
This commit is contained in:
parent
db3cdb288e
commit
b0a3207454
8 changed files with 409 additions and 2 deletions
|
@ -150,6 +150,9 @@ class HassAuthProvider(AuthProvider):
|
|||
|
||||
async def async_initialize(self):
|
||||
"""Initialize the auth provider."""
|
||||
if self.data is not None:
|
||||
return
|
||||
|
||||
self.data = Data(self.hass)
|
||||
await self.data.async_load()
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ from homeassistant.util.yaml import load_yaml
|
|||
REQUIREMENTS = ['home-assistant-frontend==20180716.0']
|
||||
|
||||
DOMAIN = 'frontend'
|
||||
DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log']
|
||||
DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', 'onboarding']
|
||||
|
||||
CONF_THEMES = 'themes'
|
||||
CONF_EXTRA_HTML_URL = 'extra_html_url'
|
||||
|
@ -377,6 +377,16 @@ class IndexView(HomeAssistantView):
|
|||
latest = self.repo_path is not None or \
|
||||
_is_latest(self.js_option, request)
|
||||
|
||||
if not hass.components.onboarding.async_is_onboarded():
|
||||
if latest:
|
||||
location = '/frontend_latest/onboarding.html'
|
||||
else:
|
||||
location = '/frontend_es5/onboarding.html'
|
||||
|
||||
return web.Response(status=302, headers={
|
||||
'location': location
|
||||
})
|
||||
|
||||
no_auth = '1'
|
||||
if hass.config.api.api_password and not request[KEY_AUTHENTICATED]:
|
||||
# do not try to auto connect on load
|
||||
|
@ -480,7 +490,7 @@ def websocket_get_translations(hass, connection, msg):
|
|||
Async friendly.
|
||||
"""
|
||||
async def send_translations():
|
||||
"""Send a camera still."""
|
||||
"""Send a translation."""
|
||||
resources = await async_get_translations(hass, msg['language'])
|
||||
connection.send_message_outside(websocket_api.result_message(
|
||||
msg['id'], {
|
||||
|
|
56
homeassistant/components/onboarding/__init__.py
Normal file
56
homeassistant/components/onboarding/__init__.py
Normal file
|
@ -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
|
7
homeassistant/components/onboarding/const.py
Normal file
7
homeassistant/components/onboarding/const.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
"""Constants for the onboarding component."""
|
||||
DOMAIN = 'onboarding'
|
||||
STEP_USER = 'user'
|
||||
|
||||
STEPS = [
|
||||
STEP_USER
|
||||
]
|
106
homeassistant/components/onboarding/views.py
Normal file
106
homeassistant/components/onboarding/views.py
Normal file
|
@ -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)
|
||||
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')
|
11
tests/components/onboarding/__init__.py
Normal file
11
tests/components/onboarding/__init__.py
Normal file
|
@ -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
|
||||
}
|
77
tests/components/onboarding/test_init.py
Normal file
77
tests/components/onboarding/test_init.py
Normal file
|
@ -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
|
137
tests/components/onboarding/test_views.py
Normal file
137
tests/components/onboarding/test_views.py
Normal file
|
@ -0,0 +1,137 @@
|
|||
"""Test the onboarding views."""
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components import onboarding
|
||||
from homeassistant.components.onboarding import views
|
||||
|
||||
from tests.common import register_auth_provider
|
||||
|
||||
from . import mock_storage
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def auth_active(hass):
|
||||
"""Ensure auth is always active."""
|
||||
hass.loop.run_until_complete(register_auth_provider(hass, {
|
||||
'type': 'homeassistant'
|
||||
}))
|
||||
|
||||
|
||||
async def test_onboarding_progress(hass, hass_storage, aiohttp_client):
|
||||
"""Test fetching progress."""
|
||||
mock_storage(hass_storage, {
|
||||
'done': ['hello']
|
||||
})
|
||||
|
||||
assert await async_setup_component(hass, 'onboarding', {})
|
||||
client = await aiohttp_client(hass.http.app)
|
||||
|
||||
with patch.object(views, 'STEPS', ['hello', 'world']):
|
||||
resp = await client.get('/api/onboarding')
|
||||
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert len(data) == 2
|
||||
assert data[0] == {
|
||||
'step': 'hello',
|
||||
'done': True
|
||||
}
|
||||
assert data[1] == {
|
||||
'step': 'world',
|
||||
'done': False
|
||||
}
|
||||
|
||||
|
||||
async def test_onboarding_user_already_done(hass, hass_storage,
|
||||
aiohttp_client):
|
||||
"""Test creating a new user when user step already done."""
|
||||
mock_storage(hass_storage, {
|
||||
'done': [views.STEP_USER]
|
||||
})
|
||||
|
||||
with patch.object(onboarding, 'STEPS', ['hello', 'world']):
|
||||
assert await async_setup_component(hass, 'onboarding', {})
|
||||
|
||||
client = await aiohttp_client(hass.http.app)
|
||||
|
||||
resp = await client.post('/api/onboarding/users', json={
|
||||
'name': 'Test Name',
|
||||
'username': 'test-user',
|
||||
'password': 'test-pass',
|
||||
})
|
||||
|
||||
assert resp.status == 403
|
||||
|
||||
|
||||
async def test_onboarding_user(hass, hass_storage, aiohttp_client):
|
||||
"""Test creating a new user."""
|
||||
mock_storage(hass_storage, {
|
||||
'done': ['hello']
|
||||
})
|
||||
|
||||
assert await async_setup_component(hass, 'onboarding', {})
|
||||
|
||||
client = await aiohttp_client(hass.http.app)
|
||||
|
||||
resp = await client.post('/api/onboarding/users', json={
|
||||
'name': 'Test Name',
|
||||
'username': 'test-user',
|
||||
'password': 'test-pass',
|
||||
})
|
||||
|
||||
assert resp.status == 200
|
||||
users = await hass.auth.async_get_users()
|
||||
assert len(users) == 1
|
||||
user = users[0]
|
||||
assert user.name == 'Test Name'
|
||||
assert len(user.credentials) == 1
|
||||
assert user.credentials[0].data['username'] == 'test-user'
|
||||
|
||||
|
||||
async def test_onboarding_user_invalid_name(hass, hass_storage,
|
||||
aiohttp_client):
|
||||
"""Test not providing name."""
|
||||
mock_storage(hass_storage, {
|
||||
'done': ['hello']
|
||||
})
|
||||
|
||||
assert await async_setup_component(hass, 'onboarding', {})
|
||||
|
||||
client = await aiohttp_client(hass.http.app)
|
||||
|
||||
resp = await client.post('/api/onboarding/users', json={
|
||||
'username': 'test-user',
|
||||
'password': 'test-pass',
|
||||
})
|
||||
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_onboarding_user_race(hass, hass_storage, aiohttp_client):
|
||||
"""Test race condition on creating new user."""
|
||||
mock_storage(hass_storage, {
|
||||
'done': ['hello']
|
||||
})
|
||||
|
||||
assert await async_setup_component(hass, 'onboarding', {})
|
||||
|
||||
client = await aiohttp_client(hass.http.app)
|
||||
|
||||
resp1 = client.post('/api/onboarding/users', json={
|
||||
'name': 'Test 1',
|
||||
'username': '1-user',
|
||||
'password': '1-pass',
|
||||
})
|
||||
resp2 = client.post('/api/onboarding/users', json={
|
||||
'name': 'Test 2',
|
||||
'username': '2-user',
|
||||
'password': '2-pass',
|
||||
})
|
||||
|
||||
res1, res2 = await asyncio.gather(resp1, resp2)
|
||||
|
||||
assert sorted([res1.status, res2.status]) == [200, 403]
|
Loading…
Add table
Reference in a new issue