Add created_at/modified_at to category registry (#122454)

pull/122464/head
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."""
return {
"category_id": entry.category_id,
"created_at": entry.created_at.timestamp(),
"icon": entry.icon,
"modified_at": entry.modified_at.timestamp(),
"name": entry.name,
}

View File

@ -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()

View File

@ -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",
}

View File

@ -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",
},
],
}
},
}