From 545514c5cd78d17361010184c8b6e5d6215b83bf Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 23 Jul 2024 14:39:38 +0200 Subject: [PATCH] Add created_at/modified_at to category registry (#122454) --- .../components/config/category_registry.py | 2 + homeassistant/helpers/category_registry.py | 46 +++++- .../config/test_category_registry.py | 39 +++++ tests/helpers/test_category_registry.py | 151 ++++++++++++++++-- 4 files changed, 218 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/config/category_registry.py b/homeassistant/components/config/category_registry.py index 5fc705a5844..ade35fddadc 100644 --- a/homeassistant/components/config/category_registry.py +++ b/homeassistant/components/config/category_registry.py @@ -130,6 +130,8 @@ def _entry_dict(entry: cr.CategoryEntry) -> dict[str, Any]: """Convert entry to API format.""" return { "category_id": entry.category_id, + "created_at": entry.created_at.timestamp(), "icon": entry.icon, + "modified_at": entry.modified_at.timestamp(), "name": entry.name, } diff --git a/homeassistant/helpers/category_registry.py b/homeassistant/helpers/category_registry.py index 6498859e2ab..41fa82084b3 100644 --- a/homeassistant/helpers/category_registry.py +++ b/homeassistant/helpers/category_registry.py @@ -5,9 +5,11 @@ from __future__ import annotations from collections.abc import Iterable import dataclasses 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.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey from homeassistant.util.ulid import ulid_now @@ -23,13 +25,16 @@ EVENT_CATEGORY_REGISTRY_UPDATED: EventType[EventCategoryRegistryUpdatedData] = ( ) STORAGE_KEY = "core.category_registry" STORAGE_VERSION_MAJOR = 1 +STORAGE_VERSION_MINOR = 2 class _CategoryStoreData(TypedDict): """Data type for individual category. Used in CategoryRegistryStoreData.""" category_id: str + created_at: str icon: str | None + modified_at: str name: str @@ -55,10 +60,36 @@ class CategoryEntry: """Category registry entry.""" category_id: str = field(default_factory=ulid_now) + created_at: datetime = field(default_factory=utcnow) icon: str | None = None + modified_at: datetime = field(default_factory=utcnow) 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 to hold a registry of categories by scope.""" @@ -66,11 +97,12 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]): """Initialize the category registry.""" self.hass = hass self.categories: dict[str, dict[str, CategoryEntry]] = {} - self._store = Store( + self._store = CategoryRegistryStore( hass, STORAGE_VERSION_MAJOR, STORAGE_KEY, atomic_writes=True, + minor_version=STORAGE_VERSION_MINOR, ) @callback @@ -145,7 +177,7 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]): ) -> CategoryEntry: """Update name or icon of the category.""" old = self.categories[scope][category_id] - changes = {} + changes: dict[str, Any] = {} if icon is not UNDEFINED and icon != old.icon: changes["icon"] = icon @@ -157,8 +189,10 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]): if not changes: return old + changes["modified_at"] = utcnow() + 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.hass.bus.async_fire_internal( @@ -180,7 +214,9 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]): category_entries[scope] = { category["category_id"]: CategoryEntry( category_id=category["category_id"], + created_at=datetime.fromisoformat(category["created_at"]), icon=category["icon"], + modified_at=datetime.fromisoformat(category["modified_at"]), name=category["name"], ) for category in categories @@ -196,7 +232,9 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]): scope: [ { "category_id": entry.category_id, + "created_at": entry.created_at.isoformat(), "icon": entry.icon, + "modified_at": entry.modified_at.isoformat(), "name": entry.name, } for entry in entries.values() diff --git a/tests/components/config/test_category_registry.py b/tests/components/config/test_category_registry.py index b4d171535b6..d4fe6a0c9b9 100644 --- a/tests/components/config/test_category_registry.py +++ b/tests/components/config/test_category_registry.py @@ -1,10 +1,14 @@ """Test category registry API.""" +from datetime import datetime + +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.config import category_registry from homeassistant.core import HomeAssistant from homeassistant.helpers import category_registry as cr +from homeassistant.util.dt import utcnow from tests.common import ANY from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -19,6 +23,7 @@ async def client_fixture( return await hass_ws_client(hass) +@pytest.mark.usefixtures("freezer") async def test_list_categories( client: MockHAClientWebSocket, category_registry: cr.CategoryRegistry, @@ -53,11 +58,15 @@ async def test_list_categories( assert len(msg["result"]) == 2 assert msg["result"][0] == { "category_id": category1.category_id, + "created_at": utcnow().timestamp(), + "modified_at": utcnow().timestamp(), "name": "Energy saving", "icon": "mdi:leaf", } assert msg["result"][1] == { "category_id": category2.category_id, + "created_at": utcnow().timestamp(), + "modified_at": utcnow().timestamp(), "name": "Something else", "icon": "mdi:home", } @@ -71,6 +80,8 @@ async def test_list_categories( assert len(msg["result"]) == 1 assert msg["result"][0] == { "category_id": category3.category_id, + "created_at": utcnow().timestamp(), + "modified_at": utcnow().timestamp(), "name": "Grocery stores", "icon": "mdi:store", } @@ -79,8 +90,11 @@ async def test_list_categories( async def test_create_category( client: MockHAClientWebSocket, category_registry: cr.CategoryRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test create entry.""" + created1 = datetime(2024, 2, 14, 12, 0, 0) + freezer.move_to(created1) await client.send_json_auto_id( { "type": "config/category_registry/create", @@ -98,9 +112,14 @@ async def test_create_category( assert msg["result"] == { "icon": "mdi:leaf", "category_id": ANY, + "created_at": created1.timestamp(), + "modified_at": created1.timestamp(), "name": "Energy saving", } + created2 = datetime(2024, 3, 14, 12, 0, 0) + freezer.move_to(created2) + await client.send_json_auto_id( { "scope": "automation", @@ -117,9 +136,14 @@ async def test_create_category( assert msg["result"] == { "icon": None, "category_id": ANY, + "created_at": created2.timestamp(), + "modified_at": created2.timestamp(), "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 await client.send_json_auto_id( { @@ -139,6 +163,8 @@ async def test_create_category( assert msg["result"] == { "icon": "mdi:leaf", "category_id": ANY, + "created_at": created3.timestamp(), + "modified_at": created3.timestamp(), "name": "Energy saving", } @@ -249,8 +275,11 @@ async def test_delete_non_existing_category( async def test_update_category( client: MockHAClientWebSocket, category_registry: cr.CategoryRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test update entry.""" + created = datetime(2024, 2, 14, 12, 0, 0) + freezer.move_to(created) category = category_registry.async_create( scope="automation", name="Energy saving", @@ -258,6 +287,9 @@ async def test_update_category( assert len(category_registry.categories) == 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( { "scope": "automation", @@ -275,9 +307,14 @@ async def test_update_category( assert msg["result"] == { "icon": "mdi:left", "category_id": category.category_id, + "created_at": created.timestamp(), + "modified_at": modified.timestamp(), "name": "ENERGY SAVING", } + modified = datetime(2024, 4, 14, 12, 0, 0) + freezer.move_to(modified) + await client.send_json_auto_id( { "scope": "automation", @@ -295,6 +332,8 @@ async def test_update_category( assert msg["result"] == { "icon": None, "category_id": category.category_id, + "created_at": created.timestamp(), + "modified_at": modified.timestamp(), "name": "Energy saving", } diff --git a/tests/helpers/test_category_registry.py b/tests/helpers/test_category_registry.py index 1317750ebec..cad997fd50f 100644 --- a/tests/helpers/test_category_registry.py +++ b/tests/helpers/test_category_registry.py @@ -1,13 +1,16 @@ """Tests for the category registry.""" +from datetime import datetime from functools import partial import re from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers import category_registry as cr +from homeassistant.util.dt import UTC 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( - hass: HomeAssistant, category_registry: cr.CategoryRegistry + hass: HomeAssistant, + category_registry: cr.CategoryRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """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) category = category_registry.async_create( scope="automation", @@ -162,9 +169,16 @@ async def test_update_category( ) assert len(category_registry.categories["automation"]) == 1 - assert category.category_id - assert category.name == "Energy saving" - assert category.icon is None + assert category == cr.CategoryEntry( + category_id=category.category_id, + 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( scope="automation", @@ -174,9 +188,13 @@ async def test_update_category( ) assert updated_category != category - assert updated_category.category_id == category.category_id - assert updated_category.name == "ENERGY SAVING" - assert updated_category.icon == "mdi:leaf" + assert updated_category == cr.CategoryEntry( + category_id=category.category_id, + created_at=created, + modified_at=modified, + name="ENERGY SAVING", + icon="mdi:leaf", + ) 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] ) -> None: """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] = { "version": cr.STORAGE_VERSION_MAJOR, + "minor_version": cr.STORAGE_VERSION_MINOR, "data": { "categories": { "automation": [ { "category_id": "uuid1", + "created_at": date_1.isoformat(), + "modified_at": date_1.isoformat(), "name": "Energy saving", "icon": "mdi:leaf", }, { "category_id": "uuid2", + "created_at": date_1.isoformat(), + "modified_at": date_2.isoformat(), "name": "Something else", "icon": None, }, @@ -362,6 +387,8 @@ async def test_loading_categories_from_storage( "zone": [ { "category_id": "uuid3", + "created_at": date_2.isoformat(), + "modified_at": date_2.isoformat(), "name": "Grocery stores", "icon": "mdi:store", }, @@ -380,21 +407,33 @@ async def test_loading_categories_from_storage( category1 = category_registry.async_get_category( scope="automation", category_id="uuid1" ) - assert category1.category_id == "uuid1" - assert category1.name == "Energy saving" - assert category1.icon == "mdi:leaf" + assert category1 == cr.CategoryEntry( + category_id="uuid1", + created_at=date_1, + modified_at=date_1, + name="Energy saving", + icon="mdi:leaf", + ) category2 = category_registry.async_get_category( scope="automation", category_id="uuid2" ) - assert category2.category_id == "uuid2" - assert category2.name == "Something else" - assert category2.icon is None + assert category2 == cr.CategoryEntry( + category_id="uuid2", + created_at=date_1, + modified_at=date_2, + name="Something else", + icon=None, + ) category3 = category_registry.async_get_category(scope="zone", category_id="uuid3") - assert category3.category_id == "uuid3" - assert category3.name == "Grocery stores" - assert category3.icon == "mdi:store" + assert category3 == cr.CategoryEntry( + category_id="uuid3", + created_at=date_2, + modified_at=date_2, + name="Grocery stores", + icon="mdi:store", + ) async def test_async_create_thread_safety( @@ -447,3 +486,83 @@ async def test_async_update_thread_safety( 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", + }, + ], + } + }, + }