Add created_at/modified_at to category registry (#122454)
parent
92acfc1464
commit
545514c5cd
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue