From 122f11c7901116eb212f5bebc14ff718467a7a11 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 4 Sep 2024 15:05:51 +0200 Subject: [PATCH] Update modified_at datetime on storage collection changes (#125218) --- homeassistant/helpers/collection.py | 37 +++++++++++-- tests/helpers/test_collection.py | 81 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 9151a9dfc6b5e5..86d3450c3a012f 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass from functools import partial +from hashlib import md5 from itertools import groupby import logging from operator import attrgetter @@ -25,6 +26,7 @@ from . import entity_registry from .entity import Entity from .entity_component import EntityComponent +from .json import json_bytes from .storage import Store from .typing import ConfigType, VolDictType @@ -50,6 +52,7 @@ class CollectionChange: change_type: str item_id: str item: Any + item_hash: str | None = None type ChangeListener = Callable[ @@ -273,7 +276,9 @@ async def async_load(self) -> None: await self.notify_changes( [ - CollectionChange(CHANGE_ADDED, item[CONF_ID], item) + CollectionChange( + CHANGE_ADDED, item[CONF_ID], item, self._hash_item(item) + ) for item in raw_storage["items"] ] ) @@ -313,7 +318,16 @@ async def async_create_item(self, data: dict) -> _ItemT: item = self._create_item(item_id, validated_data) self.data[item_id] = item self._async_schedule_save() - await self.notify_changes([CollectionChange(CHANGE_ADDED, item_id, item)]) + await self.notify_changes( + [ + CollectionChange( + CHANGE_ADDED, + item_id, + item, + self._hash_item(self._serialize_item(item_id, item)), + ) + ] + ) return item async def async_update_item(self, item_id: str, updates: dict) -> _ItemT: @@ -331,7 +345,16 @@ async def async_update_item(self, item_id: str, updates: dict) -> _ItemT: self.data[item_id] = updated self._async_schedule_save() - await self.notify_changes([CollectionChange(CHANGE_UPDATED, item_id, updated)]) + await self.notify_changes( + [ + CollectionChange( + CHANGE_UPDATED, + item_id, + updated, + self._hash_item(self._serialize_item(item_id, updated)), + ) + ] + ) return self.data[item_id] @@ -365,6 +388,10 @@ def _base_data_to_save(self) -> SerializedStorageCollection: def _data_to_save(self) -> _StoreT: """Return JSON-compatible date for storing to file.""" + def _hash_item(self, item: dict) -> str: + """Return a hash of the item.""" + return md5(json_bytes(item)).hexdigest() + class DictStorageCollection(StorageCollection[dict, SerializedStorageCollection]): """A specialized StorageCollection where the items are untyped dicts.""" @@ -464,6 +491,10 @@ async def _remove_entity(self, change_set: CollectionChange) -> None: async def _update_entity(self, change_set: CollectionChange) -> None: if entity := self.entities.get(change_set.item_id): + if change_set.item_hash: + self.ent_reg.async_update_entity_options( + entity.entity_id, "collection", {"hash": change_set.item_hash} + ) await entity.async_update_config(change_set.item) async def _collection_changed(self, change_set: Iterable[CollectionChange]) -> None: diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index f0287218d7fdc9..f564f85ec3b5d0 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -2,8 +2,10 @@ from __future__ import annotations +from datetime import timedelta import logging +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -15,6 +17,7 @@ storage, ) from homeassistant.helpers.typing import ConfigType +from homeassistant.util.dt import utcnow from tests.common import flush_store from tests.typing import WebSocketGenerator @@ -254,6 +257,84 @@ async def test_storage_collection(hass: HomeAssistant) -> None: } +async def test_storage_collection_update_modifiet_at( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that updating a storage collection will update the modified_at datetime in the entity registry.""" + + entities: dict[str, TestEntity] = {} + + class TestEntity(MockEntity): + """Entity that is config based.""" + + def __init__(self, config: ConfigType) -> None: + """Initialize entity.""" + super().__init__(config) + self._state = "initial" + + @classmethod + def from_storage(cls, config: ConfigType) -> TestEntity: + """Create instance from storage.""" + obj = super().from_storage(config) + entities[obj.unique_id] = obj + return obj + + @property + def state(self) -> str: + """Return state of entity.""" + return self._state + + def set_state(self, value: str) -> None: + """Set value.""" + self._state = value + self.async_write_ha_state() + + store = storage.Store(hass, 1, "test-data") + data = {"id": "mock-1", "name": "Mock 1", "data": 1} + await store.async_save( + { + "items": [ + data, + ] + } + ) + id_manager = collection.IDManager() + ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) + await ent_comp.async_setup({}) + coll = MockStorageCollection(store, id_manager) + collection.sync_entity_lifecycle(hass, "test", "test", ent_comp, coll, TestEntity) + changes = track_changes(coll) + + await coll.async_load() + assert id_manager.has_id("mock-1") + assert len(changes) == 1 + assert changes[0] == (collection.CHANGE_ADDED, "mock-1", data) + + modified_1 = entity_registry.async_get("test.mock_1").modified_at + assert modified_1 == utcnow() + + freezer.tick(timedelta(minutes=1)) + + updated_item = await coll.async_update_item("mock-1", {"data": 2}) + assert id_manager.has_id("mock-1") + assert updated_item == {"id": "mock-1", "name": "Mock 1", "data": 2} + assert len(changes) == 2 + assert changes[1] == (collection.CHANGE_UPDATED, "mock-1", updated_item) + + modified_2 = entity_registry.async_get("test.mock_1").modified_at + assert modified_2 > modified_1 + assert modified_2 == utcnow() + + freezer.tick(timedelta(minutes=1)) + + entities["mock-1"].set_state("second") + + modified_3 = entity_registry.async_get("test.mock_1").modified_at + assert modified_3 == modified_2 + + async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: """Test attaching collection to entity component.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass)