diff --git a/custom_components/hacs/enums.py b/custom_components/hacs/enums.py index ad0e34842fd..22ff16fc732 100644 --- a/custom_components/hacs/enums.py +++ b/custom_components/hacs/enums.py @@ -1,5 +1,4 @@ """Helper constants.""" - # pylint: disable=missing-class-docstring from enum import StrEnum @@ -28,6 +27,7 @@ class HacsDispatchEvent(StrEnum): CONFIG = "hacs_dispatch_config" ERROR = "hacs_dispatch_error" + LISTS = "hacs_dispatch_lists" RELOAD = "hacs_dispatch_reload" REPOSITORY = "hacs_dispatch_repository" REPOSITORY_DOWNLOAD_PROGRESS = "hacs_dispatch_repository_download_progress" diff --git a/custom_components/hacs/websocket/__init__.py b/custom_components/hacs/websocket/__init__.py index 6e9e2ac3460..da5a6088ce6 100644 --- a/custom_components/hacs/websocket/__init__.py +++ b/custom_components/hacs/websocket/__init__.py @@ -11,6 +11,13 @@ from ..const import DOMAIN from .critical import hacs_critical_acknowledge, hacs_critical_list +from .lists import ( + hacs_lists_create, + hacs_lists_delete, + hacs_lists_list, + hacs_lists_rename, + hacs_lists_set_repository, +) from .repositories import ( hacs_repositories_add, hacs_repositories_clear_new, @@ -40,7 +47,6 @@ def async_register_websocket_commands(hass: HomeAssistant) -> None: """Register_commands.""" websocket_api.async_register_command(hass, hacs_info) websocket_api.async_register_command(hass, hacs_subscribe) - websocket_api.async_register_command(hass, hacs_repository_info) websocket_api.async_register_command(hass, hacs_repository_download) websocket_api.async_register_command(hass, hacs_repository_ignore) @@ -50,10 +56,13 @@ def async_register_websocket_commands(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, hacs_repository_refresh) websocket_api.async_register_command(hass, hacs_repository_release_notes) websocket_api.async_register_command(hass, hacs_repository_remove) - websocket_api.async_register_command(hass, hacs_critical_acknowledge) websocket_api.async_register_command(hass, hacs_critical_list) - + websocket_api.async_register_command(hass, hacs_lists_list) + websocket_api.async_register_command(hass, hacs_lists_create) + websocket_api.async_register_command(hass, hacs_lists_rename) + websocket_api.async_register_command(hass, hacs_lists_delete) + websocket_api.async_register_command(hass, hacs_lists_set_repository) websocket_api.async_register_command(hass, hacs_repositories_list) websocket_api.async_register_command(hass, hacs_repositories_add) websocket_api.async_register_command(hass, hacs_repositories_clear_new) diff --git a/custom_components/hacs/websocket/lists.py b/custom_components/hacs/websocket/lists.py new file mode 100644 index 00000000000..7e39e3970b6 --- /dev/null +++ b/custom_components/hacs/websocket/lists.py @@ -0,0 +1,467 @@ +"""Register HACS list websocket commands.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from homeassistant.components import websocket_api +import voluptuous as vol + +from ..const import DOMAIN +from ..enums import HacsDispatchEvent +from ..utils.store import async_load_from_store, async_save_to_store + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from ..base import HacsBase + + +STORE_KEY = "lists" +LOCK_KEY = f"{DOMAIN}_lists_lock" + +FAVOURITE_ID = "favourite" +FAVOURITE_NAME = "Favourite" + +MAX_LIST_NAME_LENGTH = 100 + + +def _get_lock(hass: HomeAssistant) -> asyncio.Lock: + """Return the lock used for list mutations.""" + lock = hass.data.get(LOCK_KEY) + if lock is None: + lock = asyncio.Lock() + hass.data[LOCK_KEY] = lock + return lock + + +def _default_store() -> dict[str, Any]: + """Return the initial list store.""" + return { + "lists": { + FAVOURITE_ID: { + "name": FAVOURITE_NAME, + "repositories": {}, + } + } + } + + +async def _load_store(hass: HomeAssistant) -> dict[str, Any]: + """Load and normalize the list store.""" + data = await async_load_from_store(hass, STORE_KEY) + + if not isinstance(data, dict): + data = _default_store() + + lists = data.get("lists") + + if not isinstance(lists, dict): + data = _default_store() + lists = data["lists"] + + favourite = lists.get(FAVOURITE_ID) + + if not isinstance(favourite, dict): + lists[FAVOURITE_ID] = { + "name": FAVOURITE_NAME, + "repositories": {}, + } + else: + favourite["name"] = FAVOURITE_NAME + + if not isinstance(favourite.get("repositories"), dict): + favourite["repositories"] = {} + + for list_data in lists.values(): + if not isinstance(list_data, dict): + continue + + if not isinstance(list_data.get("repositories"), dict): + list_data["repositories"] = {} + + return data + + +async def _save_store( + hass: HomeAssistant, + data: dict[str, Any], +) -> None: + """Save the list store.""" + await async_save_to_store(hass, STORE_KEY, data) + + +def _response(data: dict[str, Any]) -> list[dict[str, Any]]: + """Convert stored lists to the websocket response.""" + response = [] + + for list_id, list_data in data["lists"].items(): + repositories = [ + { + "id": repository_id, + "full_name": full_name, + } + for repository_id, full_name in list_data["repositories"].items() + ] + + response.append( + { + "id": list_id, + "name": list_data["name"], + "builtin": list_id == FAVOURITE_ID, + "repositories": repositories, + } + ) + + return response + + +def _send_result( + connection: websocket_api.ActiveConnection, + message_id: int, + data: dict[str, Any], +) -> None: + """Send a successful websocket response.""" + connection.send_message( + websocket_api.result_message( + message_id, + _response(data), + ) + ) + + +def _send_error( + connection: websocket_api.ActiveConnection, + message_id: int, + error_code: str, + message: str, +) -> None: + """Send a websocket error response.""" + connection.send_error( + message_id, + error_code, + message, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/lists/list", + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_lists_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return all HACS lists.""" + async with _get_lock(hass): + data = await _load_store(hass) + + # Persist the built-in Favourite list if this is the first load. + await _save_store(hass, data) + + _send_result( + connection, + msg["id"], + data, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/lists/create", + vol.Required("name"): vol.All( + str, + str.strip, + vol.Length( + min=1, + max=MAX_LIST_NAME_LENGTH, + ), + ), + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_lists_create( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Create a user list.""" + async with _get_lock(hass): + data = await _load_store(hass) + + name = msg["name"] + normalized_name = name.casefold() + + if any( + list_data["name"].casefold() == normalized_name + for list_id, list_data in data["lists"].items() + if list_id != FAVOURITE_ID + ) or normalized_name == FAVOURITE_NAME.casefold(): + _send_error( + connection, + msg["id"], + "list_exists", + f"A list named '{name}' already exists.", + ) + return + + list_id = uuid4().hex + + data["lists"][list_id] = { + "name": name, + "repositories": {}, + } + + await _save_store( + hass, + data, + ) + + hacs: HacsBase = hass.data.get(DOMAIN) + + hacs.async_dispatch( + HacsDispatchEvent.LISTS, + { + "action": "create", + "list_id": list_id, + }, + ) + + _send_result( + connection, + msg["id"], + data, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/lists/rename", + vol.Required("list_id"): str, + vol.Required("name"): vol.All( + str, + str.strip, + vol.Length( + min=1, + max=MAX_LIST_NAME_LENGTH, + ), + ), + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_lists_rename( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Rename a user list.""" + async with _get_lock(hass): + data = await _load_store(hass) + + list_id = msg["list_id"] + name = msg["name"] + + if list_id == FAVOURITE_ID: + _send_error( + connection, + msg["id"], + "builtin_list", + "The Favourite list cannot be renamed.", + ) + return + + list_data = data["lists"].get(list_id) + + if list_data is None: + _send_error( + connection, + msg["id"], + "list_not_found", + f"List with ID ({list_id}) not found.", + ) + return + + normalized_name = name.casefold() + + if any( + other_id != list_id + and other_data["name"].casefold() == normalized_name + for other_id, other_data in data["lists"].items() + ) or normalized_name == FAVOURITE_NAME.casefold(): + _send_error( + connection, + msg["id"], + "list_exists", + f"A list named '{name}' already exists.", + ) + return + + list_data["name"] = name + + await _save_store( + hass, + data, + ) + + hacs: HacsBase = hass.data.get(DOMAIN) + + hacs.async_dispatch( + HacsDispatchEvent.LISTS, + { + "action": "rename", + "list_id": list_id, + }, + ) + + _send_result( + connection, + msg["id"], + data, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/lists/delete", + vol.Required("list_id"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_lists_delete( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Delete a user list.""" + async with _get_lock(hass): + data = await _load_store(hass) + + list_id = msg["list_id"] + + if list_id == FAVOURITE_ID: + _send_error( + connection, + msg["id"], + "builtin_list", + "The Favourite list cannot be deleted.", + ) + return + + if list_id not in data["lists"]: + _send_error( + connection, + msg["id"], + "list_not_found", + f"List with ID ({list_id}) not found.", + ) + return + + del data["lists"][list_id] + + await _save_store( + hass, + data, + ) + + hacs: HacsBase = hass.data.get(DOMAIN) + + hacs.async_dispatch( + HacsDispatchEvent.LISTS, + { + "action": "delete", + "list_id": list_id, + }, + ) + + _send_result( + connection, + msg["id"], + data, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "hacs/lists/set_repository", + vol.Required("repository"): str, + vol.Required("lists"): [str], + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def hacs_lists_set_repository( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Set the lists assigned to a repository.""" + hacs: HacsBase = hass.data.get(DOMAIN) + + repository_id = msg["repository"] + repository = hacs.repositories.get_by_id(repository_id) + + if repository is None: + _send_error( + connection, + msg["id"], + "repository_not_found", + f"Repository with ID ({repository_id}) not found", + ) + return + + list_ids = set(msg["lists"]) + + async with _get_lock(hass): + data = await _load_store(hass) + + unknown_lists = list_ids - set(data["lists"]) + + if unknown_lists: + _send_error( + connection, + msg["id"], + "list_not_found", + f"Unknown list IDs: {', '.join(sorted(unknown_lists))}", + ) + return + + repository_id = str(repository.data.id) + full_name = repository.data.full_name + + for list_id, list_data in data["lists"].items(): + repositories = list_data["repositories"] + + # Remove the repository from every list first. + repositories.pop(repository_id, None) + + # Then add it to the selected lists. + if list_id in list_ids: + repositories[repository_id] = full_name + + await _save_store( + hass, + data, + ) + + hacs.async_dispatch( + HacsDispatchEvent.LISTS, + { + "action": "set_repository", + "repository_id": repository_id, + }, + ) + + _send_result( + connection, + msg["id"], + data, + ) diff --git a/tests/websocket/test_lists.py b/tests/websocket/test_lists.py new file mode 100644 index 00000000000..6a7b7bd6d07 --- /dev/null +++ b/tests/websocket/test_lists.py @@ -0,0 +1,227 @@ +"""Test HACS repository lists websocket commands.""" + +from collections.abc import Generator + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from custom_components.hacs.enums import HacsDispatchEvent +from custom_components.hacs.utils.store import async_load_from_store + +from tests.common import WSClient, get_hacs + + +async def test_lists_lifecycle( + hass: HomeAssistant, + setup_integration: Generator, + ws_client: WSClient, +) -> None: + """Test creating, renaming and deleting lists.""" + messages: list[dict] = [] + unsub = async_dispatcher_connect(hass, HacsDispatchEvent.LISTS, messages.append) + try: + response = await ws_client.send_and_receive_json("hacs/lists/list", {}) + assert response["success"] is True + assert len(response["result"]) == 1 + assert response["result"][0] == { + "id": "favourite", + "name": "Favourite", + "builtin": True, + "repositories": [], + } + + response = await ws_client.send_and_receive_json( + "hacs/lists/create", + {"name": "My Integrations"}, + ) + assert response["success"] is True + created = next( + item for item in response["result"] if item["name"] == "My Integrations" + ) + assert created["builtin"] is False + assert messages[-1]["action"] == "create" + + response = await ws_client.send_and_receive_json( + "hacs/lists/rename", + {"list_id": created["id"], "name": "My Components"}, + ) + assert response["success"] is True + renamed = next( + item for item in response["result"] if item["id"] == created["id"] + ) + assert renamed["name"] == "My Components" + assert messages[-1]["action"] == "rename" + + response = await ws_client.send_and_receive_json( + "hacs/lists/delete", + {"list_id": created["id"]}, + ) + assert response["success"] is True + assert response["result"] == [ + { + "id": "favourite", + "name": "Favourite", + "builtin": True, + "repositories": [], + } + ] + assert messages[-1]["action"] == "delete" + + stored = await async_load_from_store(hass, "lists") + assert set(stored["lists"]) == {"favourite"} + finally: + unsub() + + +async def test_lists_repository_membership( + hass: HomeAssistant, + setup_integration: Generator, + ws_client: WSClient, +) -> None: + """Test repository membership in multiple lists.""" + hacs = get_hacs(hass) + repository = hacs.repositories.get_by_full_name("hacs-test-org/integration-basic") + assert repository is not None + + create_response = await ws_client.send_and_receive_json( + "hacs/lists/create", + {"name": "Components"}, + ) + assert create_response["success"] is True + components = next( + item for item in create_response["result"] if item["name"] == "Components" + ) + + response = await ws_client.send_and_receive_json( + "hacs/lists/set_repository", + { + "repository": str(repository.data.id), + "lists": ["favourite", components["id"]], + }, + ) + assert response["success"] is True + + favourite = next(item for item in response["result"] if item["id"] == "favourite") + selected = next(item for item in response["result"] if item["id"] == components["id"]) + expected_repository = { + "id": str(repository.data.id), + "full_name": repository.data.full_name, + } + assert favourite["repositories"] == [expected_repository] + assert selected["repositories"] == [expected_repository] + + # Updating membership replaces the repository's previous list assignments. + response = await ws_client.send_and_receive_json( + "hacs/lists/set_repository", + { + "repository": str(repository.data.id), + "lists": [components["id"]], + }, + ) + assert response["success"] is True + favourite = next(item for item in response["result"] if item["id"] == "favourite") + selected = next(item for item in response["result"] if item["id"] == components["id"]) + assert favourite["repositories"] == [] + assert selected["repositories"] == [expected_repository] + + stored = await async_load_from_store(hass, "lists") + assert stored["lists"][components["id"]]["repositories"] == { + str(repository.data.id): repository.data.full_name, + } + + list_response = await ws_client.send_and_receive_json("hacs/lists/list", {}) + assert list_response["success"] is True + selected = next( + item for item in list_response["result"] if item["id"] == components["id"] + ) + assert selected["repositories"] == [expected_repository] + + +async def test_lists_validation( + hass: HomeAssistant, + setup_integration: Generator, + ws_client: WSClient, +) -> None: + """Test invalid list operations are rejected.""" + hacs = get_hacs(hass) + repository = hacs.repositories.get_by_full_name("hacs-test-org/integration-basic") + assert repository is not None + + response = await ws_client.send_and_receive_json( + "hacs/lists/create", + {"name": "Components"}, + ) + assert response["success"] is True + components = next( + item for item in response["result"] if item["name"] == "Components" + ) + + response = await ws_client.send_and_receive_json( + "hacs/lists/create", + {"name": "components"}, + ) + assert response["success"] is False + assert response["error"]["code"] == "list_exists" + + response = await ws_client.send_and_receive_json( + "hacs/lists/create", + {"name": "Favourite"}, + ) + assert response["success"] is False + assert response["error"]["code"] == "list_exists" + + rename_response = await ws_client.send_and_receive_json( + "hacs/lists/rename", + {"list_id": components["id"], "name": "Favourite"}, + ) + assert rename_response["success"] is False + assert rename_response["error"]["code"] == "list_exists" + + response = await ws_client.send_and_receive_json( + "hacs/lists/rename", + {"list_id": "favourite", "name": "Pinned"}, + ) + assert response["success"] is False + assert response["error"]["code"] == "builtin_list" + + response = await ws_client.send_and_receive_json( + "hacs/lists/delete", + {"list_id": "favourite"}, + ) + assert response["success"] is False + assert response["error"]["code"] == "builtin_list" + + response = await ws_client.send_and_receive_json( + "hacs/lists/rename", + {"list_id": "missing", "name": "Renamed"}, + ) + assert response["success"] is False + assert response["error"]["code"] == "list_not_found" + + response = await ws_client.send_and_receive_json( + "hacs/lists/delete", + {"list_id": "missing"}, + ) + assert response["success"] is False + assert response["error"]["code"] == "list_not_found" + + response = await ws_client.send_and_receive_json( + "hacs/lists/set_repository", + { + "repository": "missing", + "lists": [components["id"]], + }, + ) + assert response["success"] is False + assert response["error"]["code"] == "repository_not_found" + + response = await ws_client.send_and_receive_json( + "hacs/lists/set_repository", + { + "repository": str(repository.data.id), + "lists": ["missing"], + }, + ) + assert response["success"] is False + assert response["error"]["code"] == "list_not_found" +