Add aliases to area registry items (#84294)

* Add aliases to area registry items

* Update test

* Fix WS API
This commit is contained in:
Erik Montnemery 2022-12-21 08:44:44 +01:00 committed by GitHub
parent 556c355e9c
commit 1a42bd5c4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 181 additions and 44 deletions

View File

@ -35,6 +35,7 @@ def websocket_list_areas(
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "config/area_registry/create", vol.Required("type"): "config/area_registry/create",
vol.Optional("aliases"): list,
vol.Required("name"): str, vol.Required("name"): str,
vol.Optional("picture"): vol.Any(str, None), vol.Optional("picture"): vol.Any(str, None),
} }
@ -53,6 +54,10 @@ def websocket_create_area(
data.pop("type") data.pop("type")
data.pop("id") data.pop("id")
if "aliases" in data:
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
try: try:
entry = registry.async_create(**data) entry = registry.async_create(**data)
except ValueError as err: except ValueError as err:
@ -88,6 +93,7 @@ def websocket_delete_area(
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "config/area_registry/update", vol.Required("type"): "config/area_registry/update",
vol.Optional("aliases"): list,
vol.Required("area_id"): str, vol.Required("area_id"): str,
vol.Optional("name"): str, vol.Optional("name"): str,
vol.Optional("picture"): vol.Any(str, None), vol.Optional("picture"): vol.Any(str, None),
@ -107,6 +113,10 @@ def websocket_update_area(
data.pop("type") data.pop("type")
data.pop("id") data.pop("id")
if "aliases" in data:
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
try: try:
entry = registry.async_update(**data) entry = registry.async_update(**data)
except ValueError as err: except ValueError as err:
@ -118,4 +128,9 @@ def websocket_update_area(
@callback @callback
def _entry_dict(entry): def _entry_dict(entry):
"""Convert entry to API format.""" """Convert entry to API format."""
return {"area_id": entry.id, "name": entry.name, "picture": entry.picture} return {
"aliases": entry.aliases,
"area_id": entry.id,
"name": entry.name,
"picture": entry.picture,
}

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Container, Iterable, MutableMapping from collections.abc import Container, Iterable, MutableMapping
from typing import Any, Optional, cast from typing import Any, cast
import attr import attr
@ -20,7 +20,7 @@ DATA_REGISTRY = "area_registry"
EVENT_AREA_REGISTRY_UPDATED = "area_registry_updated" EVENT_AREA_REGISTRY_UPDATED = "area_registry_updated"
STORAGE_KEY = "core.area_registry" STORAGE_KEY = "core.area_registry"
STORAGE_VERSION_MAJOR = 1 STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 2 STORAGE_VERSION_MINOR = 3
SAVE_DELAY = 10 SAVE_DELAY = 10
@ -30,8 +30,11 @@ class AreaEntry:
name: str = attr.ib() name: str = attr.ib()
normalized_name: str = attr.ib() normalized_name: str = attr.ib()
picture: str | None = attr.ib(default=None) aliases: set[str] = attr.ib(
converter=attr.converters.default_if_none(factory=set) # type: ignore[misc]
)
id: str | None = attr.ib(default=None) id: str | None = attr.ib(default=None)
picture: str | None = attr.ib(default=None)
def generate_id(self, existing_ids: Container[str]) -> None: def generate_id(self, existing_ids: Container[str]) -> None:
"""Initialize ID.""" """Initialize ID."""
@ -43,14 +46,14 @@ class AreaEntry:
object.__setattr__(self, "id", suggestion) object.__setattr__(self, "id", suggestion)
class AreaRegistryStore(Store[dict[str, list[dict[str, Optional[str]]]]]): class AreaRegistryStore(Store[dict[str, list[dict[str, Any]]]]):
"""Store area registry data.""" """Store area registry data."""
async def _async_migrate_func( async def _async_migrate_func(
self, self,
old_major_version: int, old_major_version: int,
old_minor_version: int, old_minor_version: int,
old_data: dict[str, list[dict[str, str | None]]], old_data: dict[str, list[dict[str, Any]]],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Migrate to the new version.""" """Migrate to the new version."""
if old_major_version < 2: if old_major_version < 2:
@ -60,6 +63,11 @@ class AreaRegistryStore(Store[dict[str, list[dict[str, Optional[str]]]]]):
# Populate keys which were introduced before version 1.2 # Populate keys which were introduced before version 1.2
area.setdefault("picture", None) area.setdefault("picture", None)
if old_minor_version < 3:
# Version 1.3 adds aliases
for area in old_data["areas"]:
area["aliases"] = []
if old_major_version > 1: if old_major_version > 1:
raise NotImplementedError raise NotImplementedError
return old_data return old_data
@ -107,14 +115,22 @@ class AreaRegistry:
return self.async_create(name) return self.async_create(name)
@callback @callback
def async_create(self, name: str, picture: str | None = None) -> AreaEntry: def async_create(
self,
name: str,
*,
aliases: set[str] | None = None,
picture: str | None = None,
) -> AreaEntry:
"""Create a new area.""" """Create a new area."""
normalized_name = normalize_area_name(name) normalized_name = normalize_area_name(name)
if self.async_get_area_by_name(name): if self.async_get_area_by_name(name):
raise ValueError(f"The name {name} ({normalized_name}) is already in use") raise ValueError(f"The name {name} ({normalized_name}) is already in use")
area = AreaEntry(name=name, normalized_name=normalized_name, picture=picture) area = AreaEntry(
aliases=aliases, name=name, normalized_name=normalized_name, picture=picture
)
area.generate_id(self.areas) area.generate_id(self.areas)
assert area.id is not None assert area.id is not None
self.areas[area.id] = area self.areas[area.id] = area
@ -147,11 +163,15 @@ class AreaRegistry:
def async_update( def async_update(
self, self,
area_id: str, area_id: str,
*,
aliases: set[str] | UndefinedType = UNDEFINED,
name: str | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED,
picture: str | None | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED,
) -> AreaEntry: ) -> AreaEntry:
"""Update name of area.""" """Update name of area."""
updated = self._async_update(area_id, name=name, picture=picture) updated = self._async_update(
area_id, aliases=aliases, name=name, picture=picture
)
self.hass.bus.async_fire( self.hass.bus.async_fire(
EVENT_AREA_REGISTRY_UPDATED, {"action": "update", "area_id": area_id} EVENT_AREA_REGISTRY_UPDATED, {"action": "update", "area_id": area_id}
) )
@ -161,16 +181,22 @@ class AreaRegistry:
def _async_update( def _async_update(
self, self,
area_id: str, area_id: str,
*,
aliases: set[str] | UndefinedType = UNDEFINED,
name: str | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED,
picture: str | None | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED,
) -> AreaEntry: ) -> AreaEntry:
"""Update name of area.""" """Update name of area."""
old = self.areas[area_id] old = self.areas[area_id]
changes = {} new_values = {}
if picture is not UNDEFINED: for attr_name, value in (
changes["picture"] = picture ("aliases", aliases),
("picture", picture),
):
if value is not UNDEFINED and value != getattr(old, attr_name):
new_values[attr_name] = value
normalized_name = None normalized_name = None
@ -184,13 +210,13 @@ class AreaRegistry:
f"The name {name} ({normalized_name}) is already in use" f"The name {name} ({normalized_name}) is already in use"
) )
changes["name"] = name new_values["name"] = name
changes["normalized_name"] = normalized_name new_values["normalized_name"] = normalized_name
if not changes: if not new_values:
return old return old
new = self.areas[area_id] = attr.evolve(old, **changes) new = self.areas[area_id] = attr.evolve(old, **new_values)
if normalized_name is not None: if normalized_name is not None:
self._normalized_name_area_idx[ self._normalized_name_area_idx[
normalized_name normalized_name
@ -210,6 +236,7 @@ class AreaRegistry:
assert area["name"] is not None and area["id"] is not None assert area["name"] is not None and area["id"] is not None
normalized_name = normalize_area_name(area["name"]) normalized_name = normalize_area_name(area["name"])
areas[area["id"]] = AreaEntry( areas[area["id"]] = AreaEntry(
aliases=set(area["aliases"]),
id=area["id"], id=area["id"],
name=area["name"], name=area["name"],
normalized_name=normalized_name, normalized_name=normalized_name,
@ -225,12 +252,17 @@ class AreaRegistry:
self._store.async_delay_save(self._data_to_save, SAVE_DELAY) self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
@callback @callback
def _data_to_save(self) -> dict[str, list[dict[str, str | None]]]: def _data_to_save(self) -> dict[str, list[dict[str, Any]]]:
"""Return data of area registry to store in a file.""" """Return data of area registry to store in a file."""
data = {} data = {}
data["areas"] = [ data["areas"] = [
{"name": entry.name, "id": entry.id, "picture": entry.picture} {
"aliases": list(entry.aliases),
"name": entry.name,
"id": entry.id,
"picture": entry.picture,
}
for entry in self.areas.values() for entry in self.areas.values()
] ]

View File

@ -1,9 +1,10 @@
"""Test area_registry API.""" """Test area_registry API."""
import pytest import pytest
from pytest_unordered import unordered
from homeassistant.components.config import area_registry from homeassistant.components.config import area_registry
from tests.common import mock_area_registry from tests.common import ANY, mock_area_registry
@pytest.fixture @pytest.fixture
@ -21,31 +22,68 @@ def registry(hass):
async def test_list_areas(hass, client, registry): async def test_list_areas(hass, client, registry):
"""Test list entries.""" """Test list entries."""
registry.async_create("mock 1") area1 = registry.async_create("mock 1")
registry.async_create("mock 2", "/image/example.png") area2 = registry.async_create(
"mock 2", aliases={"alias_1", "alias_2"}, picture="/image/example.png"
)
await client.send_json({"id": 1, "type": "config/area_registry/list"}) await client.send_json({"id": 1, "type": "config/area_registry/list"})
msg = await client.receive_json() msg = await client.receive_json()
assert msg["result"] == [
assert len(msg["result"]) == len(registry.areas) {
assert msg["result"][0]["name"] == "mock 1" "aliases": [],
assert msg["result"][0]["picture"] is None "area_id": area1.id,
assert msg["result"][1]["name"] == "mock 2" "name": "mock 1",
assert msg["result"][1]["picture"] == "/image/example.png" "picture": None,
},
{
"aliases": unordered(["alias_1", "alias_2"]),
"area_id": area2.id,
"name": "mock 2",
"picture": "/image/example.png",
},
]
async def test_create_area(hass, client, registry): async def test_create_area(hass, client, registry):
"""Test create entry.""" """Test create entry."""
# Create area with only mandatory parameters
await client.send_json( await client.send_json(
{"id": 1, "name": "mock", "type": "config/area_registry/create"} {"id": 1, "name": "mock", "type": "config/area_registry/create"}
) )
msg = await client.receive_json() msg = await client.receive_json()
assert "mock" in msg["result"]["name"] assert msg["result"] == {
"aliases": [],
"area_id": ANY,
"name": "mock",
"picture": None,
}
assert len(registry.areas) == 1 assert len(registry.areas) == 1
# Create area with all parameters
await client.send_json(
{
"id": 2,
"aliases": ["alias_1", "alias_2"],
"name": "mock 2",
"picture": "/image/example.png",
"type": "config/area_registry/create",
}
)
msg = await client.receive_json()
assert msg["result"] == {
"aliases": unordered(["alias_1", "alias_2"]),
"area_id": ANY,
"name": "mock 2",
"picture": "/image/example.png",
}
assert len(registry.areas) == 2
async def test_create_area_with_name_already_in_use(hass, client, registry): async def test_create_area_with_name_already_in_use(hass, client, registry):
"""Test create entry that should fail.""" """Test create entry that should fail."""
@ -100,6 +138,7 @@ async def test_update_area(hass, client, registry):
await client.send_json( await client.send_json(
{ {
"id": 1, "id": 1,
"aliases": ["alias_1", "alias_2"],
"area_id": area.id, "area_id": area.id,
"name": "mock 2", "name": "mock 2",
"picture": "/image/example.png", "picture": "/image/example.png",
@ -109,14 +148,18 @@ async def test_update_area(hass, client, registry):
msg = await client.receive_json() msg = await client.receive_json()
assert msg["result"]["area_id"] == area.id assert msg["result"] == {
assert msg["result"]["name"] == "mock 2" "aliases": unordered(["alias_1", "alias_2"]),
assert msg["result"]["picture"] == "/image/example.png" "area_id": area.id,
"name": "mock 2",
"picture": "/image/example.png",
}
assert len(registry.areas) == 1 assert len(registry.areas) == 1
await client.send_json( await client.send_json(
{ {
"id": 2, "id": 2,
"aliases": ["alias_1", "alias_1"],
"area_id": area.id, "area_id": area.id,
"picture": None, "picture": None,
"type": "config/area_registry/update", "type": "config/area_registry/update",
@ -125,9 +168,12 @@ async def test_update_area(hass, client, registry):
msg = await client.receive_json() msg = await client.receive_json()
assert msg["result"]["area_id"] == area.id assert msg["result"] == {
assert msg["result"]["name"] == "mock 2" "aliases": ["alias_1"],
assert msg["result"]["picture"] is None "area_id": area.id,
"name": "mock 2",
"picture": None,
}
assert len(registry.areas) == 1 assert len(registry.areas) == 1

View File

@ -4,7 +4,7 @@ import pytest
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import area_registry from homeassistant.helpers import area_registry
from tests.common import flush_store, mock_area_registry from tests.common import ANY, flush_store, mock_area_registry
@pytest.fixture @pytest.fixture
@ -38,17 +38,39 @@ async def test_list_areas(registry):
async def test_create_area(hass, registry, update_events): async def test_create_area(hass, registry, update_events):
"""Make sure that we can create an area.""" """Make sure that we can create an area."""
# Create area with only mandatory parameters
area = registry.async_create("mock") area = registry.async_create("mock")
assert area.id == "mock" assert area == area_registry.AreaEntry(
assert area.name == "mock" name="mock", normalized_name=ANY, aliases=set(), id=ANY, picture=None
)
assert len(registry.areas) == 1 assert len(registry.areas) == 1
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(update_events) == 1 assert len(update_events) == 1
assert update_events[0]["action"] == "create" assert update_events[-1]["action"] == "create"
assert update_events[0]["area_id"] == area.id assert update_events[-1]["area_id"] == area.id
# Create area with all parameters
area = registry.async_create(
"mock 2", aliases={"alias_1", "alias_2"}, picture="/image/example.png"
)
assert area == area_registry.AreaEntry(
name="mock 2",
normalized_name=ANY,
aliases={"alias_1", "alias_2"},
id=ANY,
picture="/image/example.png",
)
assert len(registry.areas) == 2
await hass.async_block_till_done()
assert len(update_events) == 2
assert update_events[-1]["action"] == "create"
assert update_events[-1]["area_id"] == area.id
async def test_create_area_with_name_already_in_use(hass, registry, update_events): async def test_create_area_with_name_already_in_use(hass, registry, update_events):
@ -70,7 +92,7 @@ async def test_create_area_with_id_already_in_use(registry):
"""Make sure that we can't create an area with a name already in use.""" """Make sure that we can't create an area with a name already in use."""
area1 = registry.async_create("mock") area1 = registry.async_create("mock")
updated_area1 = registry.async_update(area1.id, "New Name") updated_area1 = registry.async_update(area1.id, name="New Name")
assert updated_area1.id == area1.id assert updated_area1.id == area1.id
area2 = registry.async_create("mock") area2 = registry.async_create("mock")
@ -108,10 +130,21 @@ async def test_update_area(hass, registry, update_events):
"""Make sure that we can read areas.""" """Make sure that we can read areas."""
area = registry.async_create("mock") area = registry.async_create("mock")
updated_area = registry.async_update(area.id, name="mock1") updated_area = registry.async_update(
area.id,
aliases={"alias_1", "alias_2"},
name="mock1",
picture="/image/example.png",
)
assert updated_area != area assert updated_area != area
assert updated_area.name == "mock1" assert updated_area == area_registry.AreaEntry(
name="mock1",
normalized_name=ANY,
aliases={"alias_1", "alias_2"},
id=ANY,
picture="/image/example.png",
)
assert len(registry.areas) == 1 assert len(registry.areas) == 1
await hass.async_block_till_done() await hass.async_block_till_done()
@ -198,7 +231,16 @@ async def test_loading_area_from_storage(hass, hass_storage):
hass_storage[area_registry.STORAGE_KEY] = { hass_storage[area_registry.STORAGE_KEY] = {
"version": area_registry.STORAGE_VERSION_MAJOR, "version": area_registry.STORAGE_VERSION_MAJOR,
"minor_version": area_registry.STORAGE_VERSION_MINOR, "minor_version": area_registry.STORAGE_VERSION_MINOR,
"data": {"areas": [{"id": "12345A", "name": "mock", "picture": "blah"}]}, "data": {
"areas": [
{
"aliases": ["alias_1", "alias_2"],
"id": "12345A",
"name": "mock",
"picture": "blah",
}
]
},
} }
await area_registry.async_load(hass) await area_registry.async_load(hass)
@ -228,7 +270,9 @@ async def test_migration_from_1_1(hass, hass_storage):
"version": area_registry.STORAGE_VERSION_MAJOR, "version": area_registry.STORAGE_VERSION_MAJOR,
"minor_version": area_registry.STORAGE_VERSION_MINOR, "minor_version": area_registry.STORAGE_VERSION_MINOR,
"key": area_registry.STORAGE_KEY, "key": area_registry.STORAGE_KEY,
"data": {"areas": [{"id": "12345A", "name": "mock", "picture": None}]}, "data": {
"areas": [{"aliases": [], "id": "12345A", "name": "mock", "picture": None}]
},
} }