diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 8ed5e1abfbb..220161fb649 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol from homeassistant.components import frontend -from homeassistant.const import CONF_FILENAME, EVENT_HOMEASSISTANT_START +from homeassistant.const import CONF_FILENAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv @@ -143,6 +143,7 @@ async def async_setup(hass, config): return if change_type == collection.CHANGE_ADDED: + existing = hass.data[DOMAIN]["dashboards"].get(url_path) if existing: @@ -167,34 +168,30 @@ async def async_setup(hass, config): except ValueError: _LOGGER.warning("Failed to %s panel %s from storage", change_type, url_path) - async def async_setup_dashboards(event): - """Register dashboards on startup.""" - # Process YAML dashboards - for url_path, dashboard_conf in hass.data[DOMAIN]["yaml_dashboards"].items(): - # For now always mode=yaml - config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf) - hass.data[DOMAIN]["dashboards"][url_path] = config + # Process YAML dashboards + for url_path, dashboard_conf in hass.data[DOMAIN]["yaml_dashboards"].items(): + # For now always mode=yaml + config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf) + hass.data[DOMAIN]["dashboards"][url_path] = config - try: - _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False) - except ValueError: - _LOGGER.warning("Panel url path %s is not unique", url_path) + try: + _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False) + except ValueError: + _LOGGER.warning("Panel url path %s is not unique", url_path) - # Process storage dashboards - dashboards_collection = dashboard.DashboardsCollection(hass) + # Process storage dashboards + dashboards_collection = dashboard.DashboardsCollection(hass) - dashboards_collection.async_add_listener(storage_dashboard_changed) - await dashboards_collection.async_load() + dashboards_collection.async_add_listener(storage_dashboard_changed) + await dashboards_collection.async_load() - collection.StorageCollectionWebsocket( - dashboards_collection, - "lovelace/dashboards", - "dashboard", - STORAGE_DASHBOARD_CREATE_FIELDS, - STORAGE_DASHBOARD_UPDATE_FIELDS, - ).async_setup(hass, create_list=False) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_setup_dashboards) + collection.StorageCollectionWebsocket( + dashboards_collection, + "lovelace/dashboards", + "dashboard", + STORAGE_DASHBOARD_CREATE_FIELDS, + STORAGE_DASHBOARD_UPDATE_FIELDS, + ).async_setup(hass, create_list=False) return True diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 7205ae21cbe..8d7ee092cbe 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -76,6 +76,8 @@ def url_slug(value: Any) -> str: """Validate value is a valid url slug.""" if value is None: raise vol.Invalid("Slug should not be None") + if "-" not in value: + raise vol.Invalid("Url path needs to contain a hyphen (-)") str_value = str(value) slg = slugify(str_value, separator="-") if str_value == slg: diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index f32ac2ed1ff..38740672914 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod import logging import os import time +from typing import Optional, cast import voluptuous as vol @@ -230,8 +231,30 @@ class DashboardsCollection(collection.StorageCollection): _LOGGER, ) + async def _async_load_data(self) -> Optional[dict]: + """Load the data.""" + data = await self.store.async_load() + + if data is None: + return cast(Optional[dict], data) + + updated = False + + for item in data["items"] or []: + if "-" not in item[CONF_URL_PATH]: + updated = True + item[CONF_URL_PATH] = f"lovelace-{item[CONF_URL_PATH]}" + + if updated: + await self.store.async_save(data) + + return cast(Optional[dict], data) + async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" + if "-" not in data[CONF_URL_PATH]: + raise vol.Invalid("Url path needs to contain a hyphen (-)") + if data[CONF_URL_PATH] in self.hass.data[DATA_PANELS]: raise vol.Invalid("Panel url path needs to be unique") diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 9bfe3da38c9..1effb10be27 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -5,10 +5,13 @@ import pytest from homeassistant.components import frontend from homeassistant.components.lovelace import const, dashboard -from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component -from tests.common import async_capture_events, get_system_health_info +from tests.common import ( + assert_setup_component, + async_capture_events, + get_system_health_info, +) async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage): @@ -224,8 +227,6 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): } }, ) - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() assert hass.data[frontend.DATA_PANELS]["test-panel"].config == {"mode": "yaml"} assert hass.data[frontend.DATA_PANELS]["test-panel-no-sidebar"].config == { "mode": "yaml" @@ -306,11 +307,32 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): assert len(events) == 1 +async def test_wrong_key_dashboard_from_yaml(hass): + """Test we don't load lovelace dashboard without hyphen config from yaml.""" + with assert_setup_component(0): + assert not await async_setup_component( + hass, + "lovelace", + { + "lovelace": { + "dashboards": { + "testpanel": { + "mode": "yaml", + "filename": "bla.yaml", + "title": "Test Panel", + "icon": "mdi:test-icon", + "show_in_sidebar": False, + "require_admin": True, + } + } + } + }, + ) + + async def test_storage_dashboards(hass, hass_ws_client, hass_storage): """Test we load lovelace config from storage.""" assert await async_setup_component(hass, "lovelace", {}) - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"} client = await hass_ws_client(hass) @@ -321,12 +343,24 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): assert response["success"] assert response["result"] == [] - # Add a dashboard + # Add a wrong dashboard await client.send_json( { "id": 6, "type": "lovelace/dashboards/create", - "url_path": "created_url_path", + "url_path": "path", + "title": "Test path without hyphen", + } + ) + response = await client.receive_json() + assert not response["success"] + + # Add a dashboard + await client.send_json( + { + "id": 7, + "type": "lovelace/dashboards/create", + "url_path": "created-url-path", "require_admin": True, "title": "New Title", "icon": "mdi:map", @@ -339,10 +373,11 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): assert response["result"]["icon"] == "mdi:map" dashboard_id = response["result"]["id"] + dashboard_path = response["result"]["url_path"] - assert "created_url_path" in hass.data[frontend.DATA_PANELS] + assert "created-url-path" in hass.data[frontend.DATA_PANELS] - await client.send_json({"id": 7, "type": "lovelace/dashboards/list"}) + await client.send_json({"id": 8, "type": "lovelace/dashboards/list"}) response = await client.receive_json() assert response["success"] assert len(response["result"]) == 1 @@ -354,7 +389,7 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): # Fetch config await client.send_json( - {"id": 8, "type": "lovelace/config", "url_path": "created_url_path"} + {"id": 9, "type": "lovelace/config", "url_path": "created-url-path"} ) response = await client.receive_json() assert not response["success"] @@ -365,22 +400,22 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): await client.send_json( { - "id": 9, + "id": 10, "type": "lovelace/config/save", - "url_path": "created_url_path", + "url_path": "created-url-path", "config": {"yo": "hello"}, } ) response = await client.receive_json() assert response["success"] - assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format(dashboard_id)]["data"] == { - "config": {"yo": "hello"} - } + assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format(dashboard_path)][ + "data" + ] == {"config": {"yo": "hello"}} assert len(events) == 1 - assert events[0].data["url_path"] == "created_url_path" + assert events[0].data["url_path"] == "created-url-path" await client.send_json( - {"id": 10, "type": "lovelace/config", "url_path": "created_url_path"} + {"id": 11, "type": "lovelace/config", "url_path": "created-url-path"} ) response = await client.receive_json() assert response["success"] @@ -389,7 +424,7 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): # Update a dashboard await client.send_json( { - "id": 11, + "id": 12, "type": "lovelace/dashboards/update", "dashboard_id": dashboard_id, "require_admin": False, @@ -401,19 +436,19 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): response = await client.receive_json() assert response["success"] assert response["result"]["mode"] == "storage" - assert response["result"]["url_path"] == "created_url_path" + assert response["result"]["url_path"] == "created-url-path" assert response["result"]["title"] == "Updated Title" assert response["result"]["icon"] == "mdi:updated" assert response["result"]["show_in_sidebar"] is False assert response["result"]["require_admin"] is False # List dashboards again and make sure we see latest config - await client.send_json({"id": 12, "type": "lovelace/dashboards/list"}) + await client.send_json({"id": 13, "type": "lovelace/dashboards/list"}) response = await client.receive_json() assert response["success"] assert len(response["result"]) == 1 assert response["result"][0]["mode"] == "storage" - assert response["result"][0]["url_path"] == "created_url_path" + assert response["result"][0]["url_path"] == "created-url-path" assert response["result"][0]["title"] == "Updated Title" assert response["result"][0]["icon"] == "mdi:updated" assert response["result"][0]["show_in_sidebar"] is False @@ -421,22 +456,75 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): # Add dashboard with existing url path await client.send_json( - {"id": 13, "type": "lovelace/dashboards/create", "url_path": "created_url_path"} + {"id": 14, "type": "lovelace/dashboards/create", "url_path": "created-url-path"} ) response = await client.receive_json() assert not response["success"] # Delete dashboards await client.send_json( - {"id": 14, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id} + {"id": 15, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id} ) response = await client.receive_json() assert response["success"] - assert "created_url_path" not in hass.data[frontend.DATA_PANELS] + assert "created-url-path" not in hass.data[frontend.DATA_PANELS] assert dashboard.CONFIG_STORAGE_KEY.format(dashboard_id) not in hass_storage +async def test_storage_dashboard_migrate(hass, hass_ws_client, hass_storage): + """Test changing url path from storage config.""" + hass_storage[dashboard.DASHBOARDS_STORAGE_KEY] = { + "key": "lovelace_dashboards", + "version": 1, + "data": { + "items": [ + { + "icon": "mdi:tools", + "id": "tools", + "mode": "storage", + "require_admin": True, + "show_in_sidebar": True, + "title": "Tools", + "url_path": "tools", + }, + { + "icon": "mdi:tools", + "id": "tools2", + "mode": "storage", + "require_admin": True, + "show_in_sidebar": True, + "title": "Tools", + "url_path": "dashboard-tools", + }, + ] + }, + } + + assert await async_setup_component(hass, "lovelace", {}) + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + without_hyphen, with_hyphen = response["result"] + + assert without_hyphen["icon"] == "mdi:tools" + assert without_hyphen["id"] == "tools" + assert without_hyphen["mode"] == "storage" + assert without_hyphen["require_admin"] + assert without_hyphen["show_in_sidebar"] + assert without_hyphen["title"] == "Tools" + assert without_hyphen["url_path"] == "lovelace-tools" + + assert ( + with_hyphen + == hass_storage[dashboard.DASHBOARDS_STORAGE_KEY]["data"]["items"][1] + ) + + async def test_websocket_list_dashboards(hass, hass_ws_client): """Test listing dashboards both storage + YAML.""" assert await async_setup_component( @@ -455,9 +543,6 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): }, ) - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - client = await hass_ws_client(hass) # Create a storage dashboard @@ -465,7 +550,7 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): { "id": 6, "type": "lovelace/dashboards/create", - "url_path": "created_url_path", + "url_path": "created-url-path", "title": "Test Storage", } ) @@ -473,7 +558,7 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): assert response["success"] # List dashboards - await client.send_json({"id": 7, "type": "lovelace/dashboards/list"}) + await client.send_json({"id": 8, "type": "lovelace/dashboards/list"}) response = await client.receive_json() assert response["success"] assert len(response["result"]) == 2 @@ -486,4 +571,4 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): assert without_sb["mode"] == "storage" assert without_sb["title"] == "Test Storage" - assert without_sb["url_path"] == "created_url_path" + assert without_sb["url_path"] == "created-url-path"