Add created_at/modified_at for area registry (#122014)

pull/122071/head
Robert Resch 2024-07-17 09:54:06 +02:00 committed by GitHub
parent 054242ff0f
commit 35f84f32d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 93 additions and 11 deletions

View File

@ -5,11 +5,13 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable from collections.abc import Iterable
import dataclasses import dataclasses
from datetime import datetime
from functools import cached_property from functools import cached_property
from typing import Any, Literal, TypedDict from typing import Any, Literal, TypedDict
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify from homeassistant.util import slugify
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
@ -31,7 +33,7 @@ EVENT_AREA_REGISTRY_UPDATED: EventType[EventAreaRegistryUpdatedData] = EventType
) )
STORAGE_KEY = "core.area_registry" STORAGE_KEY = "core.area_registry"
STORAGE_VERSION_MAJOR = 1 STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 6 STORAGE_VERSION_MINOR = 7
class _AreaStoreData(TypedDict): class _AreaStoreData(TypedDict):
@ -44,6 +46,8 @@ class _AreaStoreData(TypedDict):
labels: list[str] labels: list[str]
name: str name: str
picture: str | None picture: str | None
created_at: datetime
modified_at: datetime
class AreasRegistryStoreData(TypedDict): class AreasRegistryStoreData(TypedDict):
@ -83,6 +87,8 @@ class AreaEntry(NormalizedNameBaseRegistryEntry):
"labels": list(self.labels), "labels": list(self.labels),
"name": self.name, "name": self.name,
"picture": self.picture, "picture": self.picture,
"created_at": self.created_at,
"modified_at": self.modified_at,
} }
) )
) )
@ -125,6 +131,11 @@ class AreaRegistryStore(Store[AreasRegistryStoreData]):
for area in old_data["areas"]: for area in old_data["areas"]:
area["labels"] = [] area["labels"] = []
if old_minor_version < 7:
# Version 1.7 adds created_at and modiefied_at
for area in old_data["areas"]:
area["created_at"] = area["modified_at"] = utc_from_timestamp(0)
if old_major_version > 1: if old_major_version > 1:
raise NotImplementedError raise NotImplementedError
return old_data # type: ignore[return-value] return old_data # type: ignore[return-value]
@ -315,7 +326,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
"""Update name of area.""" """Update name of area."""
old = self.areas[area_id] old = self.areas[area_id]
new_values = { new_values: dict[str, Any] = {
attr_name: value attr_name: value
for attr_name, value in ( for attr_name, value in (
("aliases", aliases), ("aliases", aliases),
@ -334,8 +345,10 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
if not new_values: if not new_values:
return old return old
new_values["modified_at"] = utcnow()
self.hass.verify_event_loop_thread("area_registry.async_update") self.hass.verify_event_loop_thread("area_registry.async_update")
new = self.areas[area_id] = dataclasses.replace(old, **new_values) # type: ignore[arg-type] new = self.areas[area_id] = dataclasses.replace(old, **new_values)
self.async_schedule_save() self.async_schedule_save()
return new return new
@ -361,6 +374,8 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
name=area["name"], name=area["name"],
normalized_name=normalized_name, normalized_name=normalized_name,
picture=area["picture"], picture=area["picture"],
created_at=area["created_at"],
modified_at=area["modified_at"],
) )
self.areas = areas self.areas = areas
@ -379,6 +394,8 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
"labels": list(entry.labels), "labels": list(entry.labels),
"name": entry.name, "name": entry.name,
"picture": entry.picture, "picture": entry.picture,
"created_at": entry.created_at,
"modified_at": entry.modified_at,
} }
for entry in self.areas.values() for entry in self.areas.values()
] ]

View File

@ -1,8 +1,11 @@
"""Provide a base class for registries that use a normalized name index.""" """Provide a base class for registries that use a normalized name index."""
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime
from functools import lru_cache from functools import lru_cache
from homeassistant.util import dt as dt_util
from .registry import BaseRegistryItems from .registry import BaseRegistryItems
@ -12,6 +15,8 @@ class NormalizedNameBaseRegistryEntry:
name: str name: str
normalized_name: str normalized_name: str
created_at: datetime = field(default_factory=dt_util.utcnow)
modified_at: datetime = field(default_factory=dt_util.utcnow)
@lru_cache(maxsize=1024) @lru_cache(maxsize=1024)

View File

@ -1,11 +1,13 @@
"""Test area_registry API.""" """Test area_registry API."""
from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from pytest_unordered import unordered from pytest_unordered import unordered
from homeassistant.components.config import area_registry from homeassistant.components.config import area_registry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar from homeassistant.helpers import area_registry as ar
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
@ -21,10 +23,17 @@ async def client_fixture(
async def test_list_areas( async def test_list_areas(
client: MockHAClientWebSocket, area_registry: ar.AreaRegistry client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test list entries.""" """Test list entries."""
created_area1 = "2024-07-16T13:30:00.900075+00:00"
freezer.move_to(created_area1)
area1 = area_registry.async_create("mock 1") area1 = area_registry.async_create("mock 1")
created_area2 = "2024-07-16T13:45:00.900075+00:00"
freezer.move_to(created_area2)
area2 = area_registry.async_create( area2 = area_registry.async_create(
"mock 2", "mock 2",
aliases={"alias_1", "alias_2"}, aliases={"alias_1", "alias_2"},
@ -46,6 +55,8 @@ async def test_list_areas(
"labels": [], "labels": [],
"name": "mock 1", "name": "mock 1",
"picture": None, "picture": None,
"created_at": created_area1,
"modified_at": created_area1,
}, },
{ {
"aliases": unordered(["alias_1", "alias_2"]), "aliases": unordered(["alias_1", "alias_2"]),
@ -55,12 +66,16 @@ async def test_list_areas(
"labels": unordered(["label_1", "label_2"]), "labels": unordered(["label_1", "label_2"]),
"name": "mock 2", "name": "mock 2",
"picture": "/image/example.png", "picture": "/image/example.png",
"created_at": created_area2,
"modified_at": created_area2,
}, },
] ]
async def test_create_area( async def test_create_area(
client: MockHAClientWebSocket, area_registry: ar.AreaRegistry client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test create entry.""" """Test create entry."""
# Create area with only mandatory parameters # Create area with only mandatory parameters
@ -78,6 +93,8 @@ async def test_create_area(
"labels": [], "labels": [],
"name": "mock", "name": "mock",
"picture": None, "picture": None,
"created_at": utcnow().isoformat(),
"modified_at": utcnow().isoformat(),
} }
assert len(area_registry.areas) == 1 assert len(area_registry.areas) == 1
@ -104,6 +121,8 @@ async def test_create_area(
"labels": unordered(["label_1", "label_2"]), "labels": unordered(["label_1", "label_2"]),
"name": "mock 2", "name": "mock 2",
"picture": "/image/example.png", "picture": "/image/example.png",
"created_at": utcnow().isoformat(),
"modified_at": utcnow().isoformat(),
} }
assert len(area_registry.areas) == 2 assert len(area_registry.areas) == 2
@ -161,10 +180,16 @@ async def test_delete_non_existing_area(
async def test_update_area( async def test_update_area(
client: MockHAClientWebSocket, area_registry: ar.AreaRegistry client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test update entry.""" """Test update entry."""
created_at = "2024-07-16T13:30:00.900075+00:00"
freezer.move_to(created_at)
area = area_registry.async_create("mock 1") area = area_registry.async_create("mock 1")
modified_at = "2024-07-16T13:45:00.900075+00:00"
freezer.move_to(modified_at)
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
@ -189,9 +214,14 @@ async def test_update_area(
"labels": unordered(["label_1", "label_2"]), "labels": unordered(["label_1", "label_2"]),
"name": "mock 2", "name": "mock 2",
"picture": "/image/example.png", "picture": "/image/example.png",
"created_at": created_at,
"modified_at": modified_at,
} }
assert len(area_registry.areas) == 1 assert len(area_registry.areas) == 1
modified_at = "2024-07-16T13:50:00.900075+00:00"
freezer.move_to(modified_at)
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
"aliases": ["alias_1", "alias_1"], "aliases": ["alias_1", "alias_1"],
@ -214,6 +244,8 @@ async def test_update_area(
"labels": [], "labels": [],
"name": "mock 2", "name": "mock 2",
"picture": None, "picture": None,
"created_at": created_at,
"modified_at": modified_at,
} }
assert len(area_registry.areas) == 1 assert len(area_registry.areas) == 1

View File

@ -1,8 +1,10 @@
"""Tests for the Area Registry.""" """Tests for the Area Registry."""
from datetime import datetime, timedelta
from functools import partial from functools import partial
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
@ -11,6 +13,7 @@ from homeassistant.helpers import (
floor_registry as fr, floor_registry as fr,
label_registry as lr, label_registry as lr,
) )
from homeassistant.util.dt import utcnow
from tests.common import ANY, async_capture_events, flush_store from tests.common import ANY, async_capture_events, flush_store
@ -24,7 +27,11 @@ async def test_list_areas(area_registry: ar.AreaRegistry) -> None:
assert len(areas) == len(area_registry.areas) assert len(areas) == len(area_registry.areas)
async def test_create_area(hass: HomeAssistant, area_registry: ar.AreaRegistry) -> None: async def test_create_area(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
area_registry: ar.AreaRegistry,
) -> None:
"""Make sure that we can create an area.""" """Make sure that we can create an area."""
update_events = async_capture_events(hass, ar.EVENT_AREA_REGISTRY_UPDATED) update_events = async_capture_events(hass, ar.EVENT_AREA_REGISTRY_UPDATED)
@ -40,9 +47,13 @@ async def test_create_area(hass: HomeAssistant, area_registry: ar.AreaRegistry)
name="mock", name="mock",
normalized_name=ANY, normalized_name=ANY,
picture=None, picture=None,
created_at=utcnow(),
modified_at=utcnow(),
) )
assert len(area_registry.areas) == 1 assert len(area_registry.areas) == 1
freezer.tick(timedelta(minutes=5))
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(update_events) == 1 assert len(update_events) == 1
@ -52,14 +63,14 @@ async def test_create_area(hass: HomeAssistant, area_registry: ar.AreaRegistry)
} }
# Create area with all parameters # Create area with all parameters
area = area_registry.async_create( area2 = area_registry.async_create(
"mock 2", "mock 2",
aliases={"alias_1", "alias_2"}, aliases={"alias_1", "alias_2"},
labels={"label1", "label2"}, labels={"label1", "label2"},
picture="/image/example.png", picture="/image/example.png",
) )
assert area == ar.AreaEntry( assert area2 == ar.AreaEntry(
aliases={"alias_1", "alias_2"}, aliases={"alias_1", "alias_2"},
floor_id=None, floor_id=None,
icon=None, icon=None,
@ -68,15 +79,19 @@ async def test_create_area(hass: HomeAssistant, area_registry: ar.AreaRegistry)
name="mock 2", name="mock 2",
normalized_name=ANY, normalized_name=ANY,
picture="/image/example.png", picture="/image/example.png",
created_at=utcnow(),
modified_at=utcnow(),
) )
assert len(area_registry.areas) == 2 assert len(area_registry.areas) == 2
assert area.created_at != area2.created_at
assert area.modified_at != area2.modified_at
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(update_events) == 2 assert len(update_events) == 2
assert update_events[-1].data == { assert update_events[-1].data == {
"action": "create", "action": "create",
"area_id": area.id, "area_id": area2.id,
} }
@ -150,11 +165,18 @@ async def test_update_area(
area_registry: ar.AreaRegistry, area_registry: ar.AreaRegistry,
floor_registry: fr.FloorRegistry, floor_registry: fr.FloorRegistry,
label_registry: lr.LabelRegistry, label_registry: lr.LabelRegistry,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Make sure that we can read areas.""" """Make sure that we can read areas."""
created_at = datetime.fromisoformat("2024-01-01T01:00:00+00:00")
freezer.move_to(created_at)
update_events = async_capture_events(hass, ar.EVENT_AREA_REGISTRY_UPDATED) update_events = async_capture_events(hass, ar.EVENT_AREA_REGISTRY_UPDATED)
floor_registry.async_create("first") floor_registry.async_create("first")
area = area_registry.async_create("mock") area = area_registry.async_create("mock")
assert area.modified_at == created_at
modified_at = datetime.fromisoformat("2024-02-01T01:00:00+00:00")
freezer.move_to(modified_at)
updated_area = area_registry.async_update( updated_area = area_registry.async_update(
area.id, area.id,
@ -176,6 +198,8 @@ async def test_update_area(
name="mock1", name="mock1",
normalized_name=ANY, normalized_name=ANY,
picture="/image/example.png", picture="/image/example.png",
created_at=created_at,
modified_at=modified_at,
) )
assert len(area_registry.areas) == 1 assert len(area_registry.areas) == 1
@ -285,6 +309,8 @@ async def test_loading_area_from_storage(
"labels": ["mock-label1", "mock-label2"], "labels": ["mock-label1", "mock-label2"],
"name": "mock", "name": "mock",
"picture": "blah", "picture": "blah",
"created_at": utcnow().isoformat(),
"modified_at": utcnow().isoformat(),
} }
] ]
}, },
@ -329,6 +355,8 @@ async def test_migration_from_1_1(
"labels": [], "labels": [],
"name": "mock", "name": "mock",
"picture": None, "picture": None,
"created_at": "1970-01-01T00:00:00+00:00",
"modified_at": "1970-01-01T00:00:00+00:00",
} }
] ]
}, },