Add created_at/modified_at to category registry (#122454)

This commit is contained in:
Robert Resch 2024-07-23 14:39:38 +02:00 committed by GitHub
parent 92acfc1464
commit 545514c5cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 218 additions and 20 deletions

View File

@ -130,6 +130,8 @@ def _entry_dict(entry: cr.CategoryEntry) -> dict[str, Any]:
"""Convert entry to API format.""" """Convert entry to API format."""
return { return {
"category_id": entry.category_id, "category_id": entry.category_id,
"created_at": entry.created_at.timestamp(),
"icon": entry.icon, "icon": entry.icon,
"modified_at": entry.modified_at.timestamp(),
"name": entry.name, "name": entry.name,
} }

View File

@ -5,9 +5,11 @@ from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
import dataclasses import dataclasses
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Literal, TypedDict from datetime import datetime
from typing import Any, Literal, TypedDict
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.util.dt import utc_from_timestamp, utcnow
from homeassistant.util.event_type import EventType from homeassistant.util.event_type import EventType
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from homeassistant.util.ulid import ulid_now from homeassistant.util.ulid import ulid_now
@ -23,13 +25,16 @@ EVENT_CATEGORY_REGISTRY_UPDATED: EventType[EventCategoryRegistryUpdatedData] = (
) )
STORAGE_KEY = "core.category_registry" STORAGE_KEY = "core.category_registry"
STORAGE_VERSION_MAJOR = 1 STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 2
class _CategoryStoreData(TypedDict): class _CategoryStoreData(TypedDict):
"""Data type for individual category. Used in CategoryRegistryStoreData.""" """Data type for individual category. Used in CategoryRegistryStoreData."""
category_id: str category_id: str
created_at: str
icon: str | None icon: str | None
modified_at: str
name: str name: str
@ -55,10 +60,36 @@ class CategoryEntry:
"""Category registry entry.""" """Category registry entry."""
category_id: str = field(default_factory=ulid_now) category_id: str = field(default_factory=ulid_now)
created_at: datetime = field(default_factory=utcnow)
icon: str | None = None icon: str | None = None
modified_at: datetime = field(default_factory=utcnow)
name: str name: str
class CategoryRegistryStore(Store[CategoryRegistryStoreData]):
"""Store category registry data."""
async def _async_migrate_func(
self,
old_major_version: int,
old_minor_version: int,
old_data: dict[str, dict[str, list[dict[str, Any]]]],
) -> CategoryRegistryStoreData:
"""Migrate to the new version."""
if old_major_version > STORAGE_VERSION_MAJOR:
raise ValueError("Can't migrate to future version")
if old_major_version == 1:
if old_minor_version < 2:
# Version 1.2 implements migration and adds created_at and modified_at
created_at = utc_from_timestamp(0).isoformat()
for categories in old_data["categories"].values():
for category in categories:
category["created_at"] = category["modified_at"] = created_at
return old_data # type: ignore[return-value]
class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]): class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]):
"""Class to hold a registry of categories by scope.""" """Class to hold a registry of categories by scope."""
@ -66,11 +97,12 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]):
"""Initialize the category registry.""" """Initialize the category registry."""
self.hass = hass self.hass = hass
self.categories: dict[str, dict[str, CategoryEntry]] = {} self.categories: dict[str, dict[str, CategoryEntry]] = {}
self._store = Store( self._store = CategoryRegistryStore(
hass, hass,
STORAGE_VERSION_MAJOR, STORAGE_VERSION_MAJOR,
STORAGE_KEY, STORAGE_KEY,
atomic_writes=True, atomic_writes=True,
minor_version=STORAGE_VERSION_MINOR,
) )
@callback @callback
@ -145,7 +177,7 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]):
) -> CategoryEntry: ) -> CategoryEntry:
"""Update name or icon of the category.""" """Update name or icon of the category."""
old = self.categories[scope][category_id] old = self.categories[scope][category_id]
changes = {} changes: dict[str, Any] = {}
if icon is not UNDEFINED and icon != old.icon: if icon is not UNDEFINED and icon != old.icon:
changes["icon"] = icon changes["icon"] = icon
@ -157,8 +189,10 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]):
if not changes: if not changes:
return old return old
changes["modified_at"] = utcnow()
self.hass.verify_event_loop_thread("category_registry.async_update") self.hass.verify_event_loop_thread("category_registry.async_update")
new = self.categories[scope][category_id] = dataclasses.replace(old, **changes) # type: ignore[arg-type] new = self.categories[scope][category_id] = dataclasses.replace(old, **changes)
self.async_schedule_save() self.async_schedule_save()
self.hass.bus.async_fire_internal( self.hass.bus.async_fire_internal(
@ -180,7 +214,9 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]):
category_entries[scope] = { category_entries[scope] = {
category["category_id"]: CategoryEntry( category["category_id"]: CategoryEntry(
category_id=category["category_id"], category_id=category["category_id"],
created_at=datetime.fromisoformat(category["created_at"]),
icon=category["icon"], icon=category["icon"],
modified_at=datetime.fromisoformat(category["modified_at"]),
name=category["name"], name=category["name"],
) )
for category in categories for category in categories
@ -196,7 +232,9 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]):
scope: [ scope: [
{ {
"category_id": entry.category_id, "category_id": entry.category_id,
"created_at": entry.created_at.isoformat(),
"icon": entry.icon, "icon": entry.icon,
"modified_at": entry.modified_at.isoformat(),
"name": entry.name, "name": entry.name,
} }
for entry in entries.values() for entry in entries.values()

View File

@ -1,10 +1,14 @@
"""Test category registry API.""" """Test category registry API."""
from datetime import datetime
from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from homeassistant.components.config import category_registry from homeassistant.components.config import category_registry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import category_registry as cr from homeassistant.helpers import category_registry as cr
from homeassistant.util.dt import utcnow
from tests.common import ANY from tests.common import ANY
from tests.typing import MockHAClientWebSocket, WebSocketGenerator from tests.typing import MockHAClientWebSocket, WebSocketGenerator
@ -19,6 +23,7 @@ async def client_fixture(
return await hass_ws_client(hass) return await hass_ws_client(hass)
@pytest.mark.usefixtures("freezer")
async def test_list_categories( async def test_list_categories(
client: MockHAClientWebSocket, client: MockHAClientWebSocket,
category_registry: cr.CategoryRegistry, category_registry: cr.CategoryRegistry,
@ -53,11 +58,15 @@ async def test_list_categories(
assert len(msg["result"]) == 2 assert len(msg["result"]) == 2
assert msg["result"][0] == { assert msg["result"][0] == {
"category_id": category1.category_id, "category_id": category1.category_id,
"created_at": utcnow().timestamp(),
"modified_at": utcnow().timestamp(),
"name": "Energy saving", "name": "Energy saving",
"icon": "mdi:leaf", "icon": "mdi:leaf",
} }
assert msg["result"][1] == { assert msg["result"][1] == {
"category_id": category2.category_id, "category_id": category2.category_id,
"created_at": utcnow().timestamp(),
"modified_at": utcnow().timestamp(),
"name": "Something else", "name": "Something else",
"icon": "mdi:home", "icon": "mdi:home",
} }
@ -71,6 +80,8 @@ async def test_list_categories(
assert len(msg["result"]) == 1 assert len(msg["result"]) == 1
assert msg["result"][0] == { assert msg["result"][0] == {
"category_id": category3.category_id, "category_id": category3.category_id,
"created_at": utcnow().timestamp(),
"modified_at": utcnow().timestamp(),
"name": "Grocery stores", "name": "Grocery stores",
"icon": "mdi:store", "icon": "mdi:store",
} }
@ -79,8 +90,11 @@ async def test_list_categories(
async def test_create_category( async def test_create_category(
client: MockHAClientWebSocket, client: MockHAClientWebSocket,
category_registry: cr.CategoryRegistry, category_registry: cr.CategoryRegistry,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test create entry.""" """Test create entry."""
created1 = datetime(2024, 2, 14, 12, 0, 0)
freezer.move_to(created1)
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
"type": "config/category_registry/create", "type": "config/category_registry/create",
@ -98,9 +112,14 @@ async def test_create_category(
assert msg["result"] == { assert msg["result"] == {
"icon": "mdi:leaf", "icon": "mdi:leaf",
"category_id": ANY, "category_id": ANY,
"created_at": created1.timestamp(),
"modified_at": created1.timestamp(),
"name": "Energy saving", "name": "Energy saving",
} }
created2 = datetime(2024, 3, 14, 12, 0, 0)
freezer.move_to(created2)
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
"scope": "automation", "scope": "automation",
@ -117,9 +136,14 @@ async def test_create_category(
assert msg["result"] == { assert msg["result"] == {
"icon": None, "icon": None,
"category_id": ANY, "category_id": ANY,
"created_at": created2.timestamp(),
"modified_at": created2.timestamp(),
"name": "Something else", "name": "Something else",
} }
created3 = datetime(2024, 4, 14, 12, 0, 0)
freezer.move_to(created3)
# Test adding the same one again in a different scope # Test adding the same one again in a different scope
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
@ -139,6 +163,8 @@ async def test_create_category(
assert msg["result"] == { assert msg["result"] == {
"icon": "mdi:leaf", "icon": "mdi:leaf",
"category_id": ANY, "category_id": ANY,
"created_at": created3.timestamp(),
"modified_at": created3.timestamp(),
"name": "Energy saving", "name": "Energy saving",
} }
@ -249,8 +275,11 @@ async def test_delete_non_existing_category(
async def test_update_category( async def test_update_category(
client: MockHAClientWebSocket, client: MockHAClientWebSocket,
category_registry: cr.CategoryRegistry, category_registry: cr.CategoryRegistry,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test update entry.""" """Test update entry."""
created = datetime(2024, 2, 14, 12, 0, 0)
freezer.move_to(created)
category = category_registry.async_create( category = category_registry.async_create(
scope="automation", scope="automation",
name="Energy saving", name="Energy saving",
@ -258,6 +287,9 @@ async def test_update_category(
assert len(category_registry.categories) == 1 assert len(category_registry.categories) == 1
assert len(category_registry.categories["automation"]) == 1 assert len(category_registry.categories["automation"]) == 1
modified = datetime(2024, 3, 14, 12, 0, 0)
freezer.move_to(modified)
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
"scope": "automation", "scope": "automation",
@ -275,9 +307,14 @@ async def test_update_category(
assert msg["result"] == { assert msg["result"] == {
"icon": "mdi:left", "icon": "mdi:left",
"category_id": category.category_id, "category_id": category.category_id,
"created_at": created.timestamp(),
"modified_at": modified.timestamp(),
"name": "ENERGY SAVING", "name": "ENERGY SAVING",
} }
modified = datetime(2024, 4, 14, 12, 0, 0)
freezer.move_to(modified)
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
"scope": "automation", "scope": "automation",
@ -295,6 +332,8 @@ async def test_update_category(
assert msg["result"] == { assert msg["result"] == {
"icon": None, "icon": None,
"category_id": category.category_id, "category_id": category.category_id,
"created_at": created.timestamp(),
"modified_at": modified.timestamp(),
"name": "Energy saving", "name": "Energy saving",
} }

View File

@ -1,13 +1,16 @@
"""Tests for the category registry.""" """Tests for the category registry."""
from datetime import datetime
from functools import partial from functools import partial
import re import re
from typing import Any from typing import Any
from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import category_registry as cr from homeassistant.helpers import category_registry as cr
from homeassistant.util.dt import UTC
from tests.common import async_capture_events, flush_store from tests.common import async_capture_events, flush_store
@ -152,9 +155,13 @@ async def test_delete_non_existing_category(
async def test_update_category( async def test_update_category(
hass: HomeAssistant, category_registry: cr.CategoryRegistry hass: HomeAssistant,
category_registry: cr.CategoryRegistry,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Make sure that we can update categories.""" """Make sure that we can update categories."""
created = datetime(2024, 2, 14, 12, 0, 0, tzinfo=UTC)
freezer.move_to(created)
update_events = async_capture_events(hass, cr.EVENT_CATEGORY_REGISTRY_UPDATED) update_events = async_capture_events(hass, cr.EVENT_CATEGORY_REGISTRY_UPDATED)
category = category_registry.async_create( category = category_registry.async_create(
scope="automation", scope="automation",
@ -162,9 +169,16 @@ async def test_update_category(
) )
assert len(category_registry.categories["automation"]) == 1 assert len(category_registry.categories["automation"]) == 1
assert category.category_id assert category == cr.CategoryEntry(
assert category.name == "Energy saving" category_id=category.category_id,
assert category.icon is None created_at=created,
modified_at=created,
name="Energy saving",
icon=None,
)
modified = datetime(2024, 3, 14, 12, 0, 0, tzinfo=UTC)
freezer.move_to(modified)
updated_category = category_registry.async_update( updated_category = category_registry.async_update(
scope="automation", scope="automation",
@ -174,9 +188,13 @@ async def test_update_category(
) )
assert updated_category != category assert updated_category != category
assert updated_category.category_id == category.category_id assert updated_category == cr.CategoryEntry(
assert updated_category.name == "ENERGY SAVING" category_id=category.category_id,
assert updated_category.icon == "mdi:leaf" created_at=created,
modified_at=modified,
name="ENERGY SAVING",
icon="mdi:leaf",
)
assert len(category_registry.categories["automation"]) == 1 assert len(category_registry.categories["automation"]) == 1
@ -343,18 +361,25 @@ async def test_loading_categories_from_storage(
hass: HomeAssistant, hass_storage: dict[str, Any] hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None: ) -> None:
"""Test loading stored categories on start.""" """Test loading stored categories on start."""
date_1 = datetime(2024, 2, 14, 12, 0, 0)
date_2 = datetime(2024, 2, 14, 12, 0, 0)
hass_storage[cr.STORAGE_KEY] = { hass_storage[cr.STORAGE_KEY] = {
"version": cr.STORAGE_VERSION_MAJOR, "version": cr.STORAGE_VERSION_MAJOR,
"minor_version": cr.STORAGE_VERSION_MINOR,
"data": { "data": {
"categories": { "categories": {
"automation": [ "automation": [
{ {
"category_id": "uuid1", "category_id": "uuid1",
"created_at": date_1.isoformat(),
"modified_at": date_1.isoformat(),
"name": "Energy saving", "name": "Energy saving",
"icon": "mdi:leaf", "icon": "mdi:leaf",
}, },
{ {
"category_id": "uuid2", "category_id": "uuid2",
"created_at": date_1.isoformat(),
"modified_at": date_2.isoformat(),
"name": "Something else", "name": "Something else",
"icon": None, "icon": None,
}, },
@ -362,6 +387,8 @@ async def test_loading_categories_from_storage(
"zone": [ "zone": [
{ {
"category_id": "uuid3", "category_id": "uuid3",
"created_at": date_2.isoformat(),
"modified_at": date_2.isoformat(),
"name": "Grocery stores", "name": "Grocery stores",
"icon": "mdi:store", "icon": "mdi:store",
}, },
@ -380,21 +407,33 @@ async def test_loading_categories_from_storage(
category1 = category_registry.async_get_category( category1 = category_registry.async_get_category(
scope="automation", category_id="uuid1" scope="automation", category_id="uuid1"
) )
assert category1.category_id == "uuid1" assert category1 == cr.CategoryEntry(
assert category1.name == "Energy saving" category_id="uuid1",
assert category1.icon == "mdi:leaf" created_at=date_1,
modified_at=date_1,
name="Energy saving",
icon="mdi:leaf",
)
category2 = category_registry.async_get_category( category2 = category_registry.async_get_category(
scope="automation", category_id="uuid2" scope="automation", category_id="uuid2"
) )
assert category2.category_id == "uuid2" assert category2 == cr.CategoryEntry(
assert category2.name == "Something else" category_id="uuid2",
assert category2.icon is None created_at=date_1,
modified_at=date_2,
name="Something else",
icon=None,
)
category3 = category_registry.async_get_category(scope="zone", category_id="uuid3") category3 = category_registry.async_get_category(scope="zone", category_id="uuid3")
assert category3.category_id == "uuid3" assert category3 == cr.CategoryEntry(
assert category3.name == "Grocery stores" category_id="uuid3",
assert category3.icon == "mdi:store" created_at=date_2,
modified_at=date_2,
name="Grocery stores",
icon="mdi:store",
)
async def test_async_create_thread_safety( async def test_async_create_thread_safety(
@ -447,3 +486,83 @@ async def test_async_update_thread_safety(
name="new name", name="new name",
) )
) )
@pytest.mark.parametrize("load_registries", [False])
async def test_migration_from_1_1(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test migration from version 1.1."""
hass_storage[cr.STORAGE_KEY] = {
"version": 1,
"data": {
"categories": {
"automation": [
{
"category_id": "uuid1",
"name": "Energy saving",
"icon": "mdi:leaf",
},
{
"category_id": "uuid2",
"name": "Something else",
"icon": None,
},
],
"zone": [
{
"category_id": "uuid3",
"name": "Grocery stores",
"icon": "mdi:store",
},
],
}
},
}
await cr.async_load(hass)
registry = cr.async_get(hass)
# Test data was loaded
assert len(registry.categories) == 2
assert len(registry.categories["automation"]) == 2
assert len(registry.categories["zone"]) == 1
assert registry.async_get_category(scope="automation", category_id="uuid1")
# Check we store migrated data
await flush_store(registry._store)
assert hass_storage[cr.STORAGE_KEY] == {
"version": cr.STORAGE_VERSION_MAJOR,
"minor_version": cr.STORAGE_VERSION_MINOR,
"key": cr.STORAGE_KEY,
"data": {
"categories": {
"automation": [
{
"category_id": "uuid1",
"created_at": "1970-01-01T00:00:00+00:00",
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "Energy saving",
"icon": "mdi:leaf",
},
{
"category_id": "uuid2",
"created_at": "1970-01-01T00:00:00+00:00",
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "Something else",
"icon": None,
},
],
"zone": [
{
"category_id": "uuid3",
"created_at": "1970-01-01T00:00:00+00:00",
"modified_at": "1970-01-01T00:00:00+00:00",
"name": "Grocery stores",
"icon": "mdi:store",
},
],
}
},
}