diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index e6862428389..dd5c4d4ec92 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -5,11 +5,13 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Iterable import dataclasses +from datetime import datetime from functools import cached_property from typing import Any, Literal, TypedDict from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify +from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey @@ -31,7 +33,7 @@ EVENT_AREA_REGISTRY_UPDATED: EventType[EventAreaRegistryUpdatedData] = EventType ) STORAGE_KEY = "core.area_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 6 +STORAGE_VERSION_MINOR = 7 class _AreaStoreData(TypedDict): @@ -44,6 +46,8 @@ class _AreaStoreData(TypedDict): labels: list[str] name: str picture: str | None + created_at: datetime + modified_at: datetime class AreasRegistryStoreData(TypedDict): @@ -83,6 +87,8 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): "labels": list(self.labels), "name": self.name, "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"]: 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: raise NotImplementedError return old_data # type: ignore[return-value] @@ -315,7 +326,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): """Update name of area.""" old = self.areas[area_id] - new_values = { + new_values: dict[str, Any] = { attr_name: value for attr_name, value in ( ("aliases", aliases), @@ -334,8 +345,10 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): if not new_values: return old + new_values["modified_at"] = utcnow() + 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() return new @@ -361,6 +374,8 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): name=area["name"], normalized_name=normalized_name, picture=area["picture"], + created_at=area["created_at"], + modified_at=area["modified_at"], ) self.areas = areas @@ -379,6 +394,8 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): "labels": list(entry.labels), "name": entry.name, "picture": entry.picture, + "created_at": entry.created_at, + "modified_at": entry.modified_at, } for entry in self.areas.values() ] diff --git a/homeassistant/helpers/normalized_name_base_registry.py b/homeassistant/helpers/normalized_name_base_registry.py index 1cffac9ffc5..7e7ca9ed884 100644 --- a/homeassistant/helpers/normalized_name_base_registry.py +++ b/homeassistant/helpers/normalized_name_base_registry.py @@ -1,8 +1,11 @@ """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 homeassistant.util import dt as dt_util + from .registry import BaseRegistryItems @@ -12,6 +15,8 @@ class NormalizedNameBaseRegistryEntry: 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) diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py index fb59725fd29..ed2c1866ad9 100644 --- a/tests/components/config/test_area_registry.py +++ b/tests/components/config/test_area_registry.py @@ -1,11 +1,13 @@ """Test area_registry API.""" +from freezegun.api import FrozenDateTimeFactory import pytest from pytest_unordered import unordered from homeassistant.components.config import area_registry from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar +from homeassistant.util.dt import utcnow from tests.common import ANY from tests.typing import MockHAClientWebSocket, WebSocketGenerator @@ -21,10 +23,17 @@ async def client_fixture( async def test_list_areas( - client: MockHAClientWebSocket, area_registry: ar.AreaRegistry + client: MockHAClientWebSocket, + area_registry: ar.AreaRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """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") + + created_area2 = "2024-07-16T13:45:00.900075+00:00" + freezer.move_to(created_area2) area2 = area_registry.async_create( "mock 2", aliases={"alias_1", "alias_2"}, @@ -46,6 +55,8 @@ async def test_list_areas( "labels": [], "name": "mock 1", "picture": None, + "created_at": created_area1, + "modified_at": created_area1, }, { "aliases": unordered(["alias_1", "alias_2"]), @@ -55,12 +66,16 @@ async def test_list_areas( "labels": unordered(["label_1", "label_2"]), "name": "mock 2", "picture": "/image/example.png", + "created_at": created_area2, + "modified_at": created_area2, }, ] async def test_create_area( - client: MockHAClientWebSocket, area_registry: ar.AreaRegistry + client: MockHAClientWebSocket, + area_registry: ar.AreaRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test create entry.""" # Create area with only mandatory parameters @@ -78,6 +93,8 @@ async def test_create_area( "labels": [], "name": "mock", "picture": None, + "created_at": utcnow().isoformat(), + "modified_at": utcnow().isoformat(), } assert len(area_registry.areas) == 1 @@ -104,6 +121,8 @@ async def test_create_area( "labels": unordered(["label_1", "label_2"]), "name": "mock 2", "picture": "/image/example.png", + "created_at": utcnow().isoformat(), + "modified_at": utcnow().isoformat(), } assert len(area_registry.areas) == 2 @@ -161,10 +180,16 @@ async def test_delete_non_existing_area( async def test_update_area( - client: MockHAClientWebSocket, area_registry: ar.AreaRegistry + client: MockHAClientWebSocket, + area_registry: ar.AreaRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """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") + modified_at = "2024-07-16T13:45:00.900075+00:00" + freezer.move_to(modified_at) await client.send_json_auto_id( { @@ -189,9 +214,14 @@ async def test_update_area( "labels": unordered(["label_1", "label_2"]), "name": "mock 2", "picture": "/image/example.png", + "created_at": created_at, + "modified_at": modified_at, } 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( { "aliases": ["alias_1", "alias_1"], @@ -214,6 +244,8 @@ async def test_update_area( "labels": [], "name": "mock 2", "picture": None, + "created_at": created_at, + "modified_at": modified_at, } assert len(area_registry.areas) == 1 diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index e6d637d1a99..ad571ac50cc 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -1,8 +1,10 @@ """Tests for the Area Registry.""" +from datetime import datetime, timedelta from functools import partial from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.core import HomeAssistant @@ -11,6 +13,7 @@ from homeassistant.helpers import ( floor_registry as fr, label_registry as lr, ) +from homeassistant.util.dt import utcnow 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) -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.""" 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", normalized_name=ANY, picture=None, + created_at=utcnow(), + modified_at=utcnow(), ) assert len(area_registry.areas) == 1 + freezer.tick(timedelta(minutes=5)) + await hass.async_block_till_done() 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 - area = area_registry.async_create( + area2 = area_registry.async_create( "mock 2", aliases={"alias_1", "alias_2"}, labels={"label1", "label2"}, picture="/image/example.png", ) - assert area == ar.AreaEntry( + assert area2 == ar.AreaEntry( aliases={"alias_1", "alias_2"}, floor_id=None, icon=None, @@ -68,15 +79,19 @@ async def test_create_area(hass: HomeAssistant, area_registry: ar.AreaRegistry) name="mock 2", normalized_name=ANY, picture="/image/example.png", + created_at=utcnow(), + modified_at=utcnow(), ) 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() assert len(update_events) == 2 assert update_events[-1].data == { "action": "create", - "area_id": area.id, + "area_id": area2.id, } @@ -150,11 +165,18 @@ async def test_update_area( area_registry: ar.AreaRegistry, floor_registry: fr.FloorRegistry, label_registry: lr.LabelRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """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) floor_registry.async_create("first") 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( area.id, @@ -176,6 +198,8 @@ async def test_update_area( name="mock1", normalized_name=ANY, picture="/image/example.png", + created_at=created_at, + modified_at=modified_at, ) assert len(area_registry.areas) == 1 @@ -285,6 +309,8 @@ async def test_loading_area_from_storage( "labels": ["mock-label1", "mock-label2"], "name": "mock", "picture": "blah", + "created_at": utcnow().isoformat(), + "modified_at": utcnow().isoformat(), } ] }, @@ -329,6 +355,8 @@ async def test_migration_from_1_1( "labels": [], "name": "mock", "picture": None, + "created_at": "1970-01-01T00:00:00+00:00", + "modified_at": "1970-01-01T00:00:00+00:00", } ] },