diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index c2f03341d20..7fd767f4a43 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -97,8 +97,7 @@ class AuthProvidersView(HomeAssistantView): async def get(self, request): """Get available auth providers.""" hass = request.app['hass'] - - if not hass.components.onboarding.async_is_onboarded(): + if not hass.components.onboarding.async_is_user_onboarded(): return self.json_message( message='Onboarding not finished', status_code=400, diff --git a/homeassistant/components/onboarding/.translations/en.json b/homeassistant/components/onboarding/.translations/en.json new file mode 100644 index 00000000000..aa591e7f1fa --- /dev/null +++ b/homeassistant/components/onboarding/.translations/en.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Bedroom", + "kitchen": "Kitchen", + "living_room": "Living Room" + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index 29371369c70..55bba8f4efe 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -1,24 +1,42 @@ """Support to help onboard new users.""" from homeassistant.core import callback from homeassistant.loader import bind_hass +from homeassistant.helpers.storage import Store -from .const import DOMAIN, STEP_USER, STEPS +from .const import DOMAIN, STEP_USER, STEPS, STEP_INTEGRATION STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 +STORAGE_VERSION = 2 + + +class OnboadingStorage(Store): + """Store onboarding data.""" + + async def _async_migrate_func(self, old_version, old_data): + """Migrate to the new version.""" + # From version 1 -> 2, we automatically mark the integration step done + old_data['done'].append(STEP_INTEGRATION) + return old_data @bind_hass @callback def async_is_onboarded(hass): """Return if Home Assistant has been onboarded.""" - return hass.data.get(DOMAIN, True) + data = hass.data.get(DOMAIN) + return data is None or data is True + + +@bind_hass +@callback +def async_is_user_onboarded(hass): + """Return if a user has been created as part of onboarding.""" + return async_is_onboarded(hass) or STEP_USER in hass.data[DOMAIN]['done'] async def async_setup(hass, config): """Set up the onboarding component.""" - store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True) + store = OnboadingStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True) data = await store.async_load() if data is None: @@ -43,7 +61,7 @@ async def async_setup(hass, config): if set(data['done']) == set(STEPS): return True - hass.data[DOMAIN] = False + hass.data[DOMAIN] = data from . import views diff --git a/homeassistant/components/onboarding/const.py b/homeassistant/components/onboarding/const.py index 3aa106ac18c..fe1b28fc316 100644 --- a/homeassistant/components/onboarding/const.py +++ b/homeassistant/components/onboarding/const.py @@ -1,7 +1,15 @@ """Constants for the onboarding component.""" DOMAIN = 'onboarding' STEP_USER = 'user' +STEP_INTEGRATION = 'integration' STEPS = [ - STEP_USER + STEP_USER, + STEP_INTEGRATION, ] + +DEFAULT_AREAS = ( + 'living_room', + 'kitchen', + 'bedroom', +) diff --git a/homeassistant/components/onboarding/strings.json b/homeassistant/components/onboarding/strings.json new file mode 100644 index 00000000000..9e3806927d2 --- /dev/null +++ b/homeassistant/components/onboarding/strings.json @@ -0,0 +1,7 @@ +{ + "area": { + "living_room": "Living Room", + "bedroom": "Bedroom", + "kitchen": "Kitchen" + } +} diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index d9631b77a20..a156fe4676f 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -7,13 +7,14 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import callback -from .const import DOMAIN, STEP_USER, STEPS +from .const import DOMAIN, STEP_USER, STEPS, DEFAULT_AREAS, STEP_INTEGRATION async def async_setup(hass, data, store): """Set up the onboarding view.""" hass.http.register_view(OnboardingView(data, store)) hass.http.register_view(UserOnboardingView(data, store)) + hass.http.register_view(IntegrationOnboardingView(data, store)) class OnboardingView(HomeAssistantView): @@ -41,7 +42,6 @@ class OnboardingView(HomeAssistantView): class _BaseOnboardingView(HomeAssistantView): """Base class for onboarding.""" - requires_auth = False step = None def __init__(self, data, store): @@ -60,14 +60,16 @@ class _BaseOnboardingView(HomeAssistantView): self._data['done'].append(self.step) await self._store.async_save(self._data) - hass.data[DOMAIN] = len(self._data) == len(STEPS) + if set(self._data['done']) == set(STEPS): + hass.data[DOMAIN] = True class UserOnboardingView(_BaseOnboardingView): - """View to handle onboarding.""" + """View to handle create user onboarding step.""" url = '/api/onboarding/users' name = 'api:onboarding:users' + requires_auth = False step = STEP_USER @RequestDataValidator(vol.Schema({ @@ -75,9 +77,10 @@ class UserOnboardingView(_BaseOnboardingView): vol.Required('username'): str, vol.Required('password'): str, vol.Required('client_id'): str, + vol.Required('language'): str, })) async def post(self, request, data): - """Return the manifest.json.""" + """Handle user creation, area creation.""" hass = request.app['hass'] async with self._lock: @@ -100,14 +103,58 @@ class UserOnboardingView(_BaseOnboardingView): data['name'], user_id=user.id ) + # Create default areas using the users supplied language. + translations = \ + await hass.helpers.translation.async_get_translations( + data['language']) + + area_registry = \ + await hass.helpers.area_registry.async_get_registry() + + for area in DEFAULT_AREAS: + area_registry.async_create( + translations['component.onboarding.area.{}'.format(area)] + ) + await self._async_mark_done(hass) - # Return an authorization code to allow fetching tokens. + # Return authorization code for fetching tokens and connect + # during onboarding. auth_code = hass.components.auth.create_auth_code( data['client_id'], user ) return self.json({ - 'auth_code': auth_code + 'auth_code': auth_code, + }) + + +class IntegrationOnboardingView(_BaseOnboardingView): + """View to finish integration onboarding step.""" + + url = '/api/onboarding/integration' + name = 'api:onboarding:integration' + step = STEP_INTEGRATION + + @RequestDataValidator(vol.Schema({ + vol.Required('client_id'): str, + })) + async def post(self, request, data): + """Handle user creation, area creation.""" + hass = request.app['hass'] + user = request['hass_user'] + + async with self._lock: + if self._async_is_done(): + return self.json_message('Integration step already done', 403) + + await self._async_mark_done(hass) + + # Return authorization code so we can redirect user and log them in + auth_code = hass.components.auth.create_auth_code( + data['client_id'], user + ) + return self.json({ + 'auth_code': auth_code, }) diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index d759bac74b7..6b8ae9b75a5 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -21,7 +21,7 @@ async def test_fetch_auth_providers(hass, aiohttp_client): async def test_fetch_auth_providers_onboarding(hass, aiohttp_client): """Test fetching auth providers.""" client = await async_setup_auth(hass, aiohttp_client) - with patch('homeassistant.components.onboarding.async_is_onboarded', + with patch('homeassistant.components.onboarding.async_is_user_onboarded', return_value=False): resp = await client.get('/auth/providers') assert resp.status == 400 diff --git a/tests/components/onboarding/test_init.py b/tests/components/onboarding/test_init.py index 483b917a63e..68b73135387 100644 --- a/tests/components/onboarding/test_init.py +++ b/tests/components/onboarding/test_init.py @@ -51,10 +51,28 @@ async def test_is_onboarded(): hass.data[onboarding.DOMAIN] = True assert onboarding.async_is_onboarded(hass) - hass.data[onboarding.DOMAIN] = False + hass.data[onboarding.DOMAIN] = { + 'done': [] + } assert not onboarding.async_is_onboarded(hass) +async def test_is_user_onboarded(): + """Test the is onboarded function.""" + hass = Mock() + hass.data = {} + + assert onboarding.async_is_user_onboarded(hass) + + hass.data[onboarding.DOMAIN] = True + assert onboarding.async_is_user_onboarded(hass) + + hass.data[onboarding.DOMAIN] = { + 'done': [] + } + assert not onboarding.async_is_user_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) @@ -70,3 +88,15 @@ async def test_having_owner_finishes_user_step(hass, hass_storage): done = hass_storage[onboarding.STORAGE_KEY]['data']['done'] assert onboarding.STEP_USER in done + + +async def test_migration(hass, hass_storage): + """Test migrating onboarding to new version.""" + hass_storage[onboarding.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'done': ["user"] + } + } + assert await async_setup_component(hass, 'onboarding', {}) + assert onboarding.async_is_onboarded(hass) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index fdf472f3b13..4e253741286 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -6,7 +6,7 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components import onboarding -from homeassistant.components.onboarding import views +from homeassistant.components.onboarding import const, views from tests.common import CLIENT_ID, register_auth_provider @@ -63,6 +63,7 @@ async def test_onboarding_user_already_done(hass, hass_storage, 'name': 'Test Name', 'username': 'test-user', 'password': 'test-pass', + 'language': 'en', }) assert resp.status == 403 @@ -71,10 +72,6 @@ async def test_onboarding_user_already_done(hass, hass_storage, async def test_onboarding_user(hass, hass_storage, aiohttp_client): """Test creating a new user.""" assert await async_setup_component(hass, 'person', {}) - mock_storage(hass_storage, { - 'done': ['hello'] - }) - assert await async_setup_component(hass, 'onboarding', {}) client = await aiohttp_client(hass.http.app) @@ -84,9 +81,12 @@ async def test_onboarding_user(hass, hass_storage, aiohttp_client): 'name': 'Test Name', 'username': 'test-user', 'password': 'test-pass', + 'language': 'en', }) assert resp.status == 200 + assert const.STEP_USER in hass_storage[const.DOMAIN]['data']['done'] + data = await resp.json() assert 'auth_code' in data @@ -98,7 +98,7 @@ async def test_onboarding_user(hass, hass_storage, aiohttp_client): assert user.credentials[0].data['username'] == 'test-user' assert len(hass.data['person'].storage_data) == 1 - # Request refresh tokens + # Validate refresh token 1 resp = await client.post('/auth/token', data={ 'client_id': CLIENT_ID, 'grant_type': 'authorization_code', @@ -113,12 +113,20 @@ async def test_onboarding_user(hass, hass_storage, aiohttp_client): is not None ) + # Validate created areas + area_registry = await hass.helpers.area_registry.async_get_registry() + assert len(area_registry.areas) == 3 + assert sorted([area.name for area + in area_registry.async_list_areas()]) == [ + 'Bedroom', 'Kitchen', 'Living Room' + ] + async def test_onboarding_user_invalid_name(hass, hass_storage, aiohttp_client): """Test not providing name.""" mock_storage(hass_storage, { - 'done': ['hello'] + 'done': [] }) assert await async_setup_component(hass, 'onboarding', {}) @@ -129,6 +137,7 @@ async def test_onboarding_user_invalid_name(hass, hass_storage, 'client_id': CLIENT_ID, 'username': 'test-user', 'password': 'test-pass', + 'language': 'en', }) assert resp.status == 400 @@ -149,14 +158,69 @@ async def test_onboarding_user_race(hass, hass_storage, aiohttp_client): 'name': 'Test 1', 'username': '1-user', 'password': '1-pass', + 'language': 'en', }) resp2 = client.post('/api/onboarding/users', json={ 'client_id': CLIENT_ID, 'name': 'Test 2', 'username': '2-user', 'password': '2-pass', + 'language': 'es', }) res1, res2 = await asyncio.gather(resp1, resp2) assert sorted([res1.status, res2.status]) == [200, 403] + + +async def test_onboarding_integration(hass, hass_storage, hass_client): + """Test finishing integration step.""" + mock_storage(hass_storage, { + 'done': [const.STEP_USER] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await hass_client() + + resp = await client.post('/api/onboarding/integration', json={ + 'client_id': CLIENT_ID, + }) + + assert resp.status == 200 + data = await resp.json() + assert 'auth_code' in data + + # Validate refresh token + resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, + 'grant_type': 'authorization_code', + 'code': data['auth_code'] + }) + + assert resp.status == 200 + assert const.STEP_INTEGRATION in hass_storage[const.DOMAIN]['data']['done'] + tokens = await resp.json() + + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is not None + ) + + +async def test_onboarding_integration_requires_auth(hass, hass_storage, + aiohttp_client): + """Test finishing integration step.""" + mock_storage(hass_storage, { + 'done': [const.STEP_USER] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/integration', json={ + 'client_id': CLIENT_ID, + }) + + assert resp.status == 401