From b1c42f10d02cb77fba9eaa3786053fdff2f174e0 Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Fri, 18 Sep 2026 16:03:42 -0400 Subject: [PATCH] Regenerate child instances after presence service writes Presence pattern and override handlers now call regenerate_for_child. Definition edit/activate pass HA-local today and configured horizon. --- custom_components/nestquest/dao_presence.py | 9 ++ custom_components/nestquest/services.py | 73 +++++++++--- tests/test_dao_presence.py | 14 +++ tests/test_services.py | 120 ++++++++++++++++++++ 4 files changed, 199 insertions(+), 17 deletions(-) diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index cf1707a..c273287 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -270,6 +270,15 @@ class PresenceOverridesDao: ) return [_override_from_row(row) for row in rows] + async def get(self, override_id: int) -> PresenceOverrideRecord | None: + """Return one override by id, or None when it does not exist.""" + row = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + "WHERE id = ?", + (override_id,), + ) + return _override_from_row(row) if row is not None else None + async def delete(self, override_id: int) -> bool: """Remove one override by id; True if a row was removed.""" result = await self._database.execute( diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index c6d9fb2..0e7ee18 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -11,8 +11,10 @@ the Feature 07 handler stays the one in ``__init__``. """ from __future__ import annotations +import datetime from collections.abc import Awaitable, Callable from typing import Any +from zoneinfo import ZoneInfo import voluptuous as vol from homeassistant.core import HomeAssistant @@ -22,6 +24,8 @@ from . import children as children_layer from . import completion as completion_layer from . import quest_definitions as quest_definitions_layer from .const import ( + CONF_HORIZON_DAYS, + DEFAULT_HORIZON_DAYS, DOMAIN, DOMAIN_SERVICES, QUEST_WINDOWS, @@ -38,6 +42,7 @@ from .const import ( SERVICE_UPDATE_QUEST_DEFINITION, ) from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao +from .materialize import regenerate_for_child from .presence import PresenceSchedule from .recurrence import ScheduleRule @@ -225,13 +230,24 @@ SCHEMA_MANAGE_CHILD = vol.Schema( ) -def _require_database(hass: HomeAssistant, find_runtime: FindRuntime): +def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime): runtime = find_runtime(hass) if runtime is None: raise HomeAssistantError( "NestQuest has no live database; cannot run this service" ) - return runtime.database + return runtime + + +def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]: + time_zone = ZoneInfo(hass.config.time_zone) + today = datetime.datetime.now(time_zone).date() + value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS) + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + horizon_days = DEFAULT_HORIZON_DAYS + else: + horizon_days = value + return {"today": today, "horizon_days": horizon_days} def _actor_kwargs(call: Any) -> dict[str, Any]: @@ -274,7 +290,7 @@ def _complete_quest( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + database = _require_runtime(hass, find_runtime).database await completion_layer.complete_instance( database, call.data["instance_id"], @@ -288,7 +304,7 @@ def _uncomplete_quest( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + database = _require_runtime(hass, find_runtime).database await completion_layer.uncomplete_instance( database, call.data["instance_id"], @@ -302,7 +318,7 @@ def _create_quest_definition( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + database = _require_runtime(hass, find_runtime).database data = call.data await quest_definitions_layer.create_quest_definition( database, @@ -321,9 +337,9 @@ def _update_quest_definition( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + runtime = _require_runtime(hass, find_runtime) data = call.data - kwargs: dict[str, Any] = {} + kwargs: dict[str, Any] = _generation_kwargs(hass, runtime) if "title" in data: kwargs["title"] = data["title"] if "description" in data: @@ -335,7 +351,7 @@ def _update_quest_definition( if "windows" in data: kwargs["windows"] = data["windows"] await quest_definitions_layer.edit_quest_definition( - database, data["definition_id"], **kwargs + runtime.database, data["definition_id"], **kwargs ) return _with_errors(_handler) @@ -345,11 +361,12 @@ def _set_quest_definition_active( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + runtime = _require_runtime(hass, find_runtime) await quest_definitions_layer.set_quest_definition_active( - database, + runtime.database, call.data["definition_id"], call.data["is_active"], + **_generation_kwargs(hass, runtime), ) return _with_errors(_handler) @@ -359,7 +376,7 @@ def _set_presence_pattern( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + runtime = _require_runtime(hass, find_runtime) data = call.data schedule = PresenceSchedule( data["child_id"], @@ -367,12 +384,17 @@ def _set_presence_pattern( data["anchor_date"], data["pattern"], ) - await PresenceSchedulesDao(database).upsert_by_child( + await PresenceSchedulesDao(runtime.database).upsert_by_child( schedule.child_id, schedule.cycle_length_weeks, schedule.anchor_date.isoformat(), schedule.encode(), ) + await regenerate_for_child( + runtime.database, + schedule.child_id, + **_generation_kwargs(hass, runtime), + ) return _with_errors(_handler) @@ -381,15 +403,20 @@ def _create_presence_override( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + runtime = _require_runtime(hass, find_runtime) data = call.data - await PresenceOverridesDao(database).create( + await PresenceOverridesDao(runtime.database).create( data["child_id"], data["start_date"], data["end_date"], data["is_present"], note=data.get("note"), ) + await regenerate_for_child( + runtime.database, + data["child_id"], + **_generation_kwargs(hass, runtime), + ) return _with_errors(_handler) @@ -398,8 +425,20 @@ def _delete_presence_override( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) - await PresenceOverridesDao(database).delete(call.data["override_id"]) + runtime = _require_runtime(hass, find_runtime) + dao = PresenceOverridesDao(runtime.database) + override_id = call.data["override_id"] + override = await dao.get(override_id) + if override is None: + raise HomeAssistantError( + f"override_id: presence override {override_id} does not exist" + ) + await dao.delete(override_id) + await regenerate_for_child( + runtime.database, + override.child_id, + **_generation_kwargs(hass, runtime), + ) return _with_errors(_handler) @@ -414,7 +453,7 @@ def _manage_child( hass: HomeAssistant, find_runtime: FindRuntime ) -> ServiceHandler: async def _handler(call: Any) -> None: - database = _require_database(hass, find_runtime) + database = _require_runtime(hass, find_runtime).database data = call.data action = data["action"] if action == MANAGE_CHILD_CREATE: diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index 0d86cb8..b1ee155 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -455,6 +455,20 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None: assert _with_db(tmp_path, "override-list-unknown.db")(_body) == [] +def test_override_get_round_trip(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + record = await overrides.create( + child.id, "2026-09-10", "2026-09-11", True, note="camp" + ) + found = await overrides.get(record.id) + assert found == record + assert await overrides.get(999) is None + return found + + found = _with_db(tmp_path, "override-get.db")(_body) + assert found.note == "camp" + + def test_override_delete_round_trip(tmp_path) -> None: async def _body(database, schedules, overrides, children, child): record = await overrides.create( diff --git a/tests/test_services.py b/tests/test_services.py index 1f2a2dd..3c63194 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -15,10 +15,13 @@ from custom_components.nestquest.const import ( DOMAIN, DOMAIN_SERVICES, SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_PRESENCE_OVERRIDE, SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_DELETE_PRESENCE_OVERRIDE, SERVICE_EXPORT_HISTORY_CSV, SERVICE_MANAGE_CHILD, SERVICE_REGENERATE, + SERVICE_SET_PRESENCE_PATTERN, SERVICE_SET_QUEST_DEFINITION_ACTIVE, SERVICE_UNCOMPLETE_QUEST, ) @@ -246,6 +249,123 @@ async def test_complete_and_uncomplete_quest_call_business_layer( ) +async def test_set_presence_pattern_regenerates_child_instances( + hass, make_entry +) -> None: + import datetime + + from custom_components.nestquest.children import list_children + from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + today = datetime.date.today().isoformat() + end = ( + datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + dao = QuestInstancesDao(entry.runtime_data.database) + assert await dao.list_by_date_range(child.id, today, end) + await hass.services.call( + DOMAIN, + SERVICE_SET_PRESENCE_PATTERN, + { + "child_id": child.id, + "cycle_length_weeks": 1, + "anchor_date": today, + "pattern": {0: []}, + }, + ) + assert await dao.list_by_date_range(child.id, today, end) == [] + + +async def test_presence_override_create_and_delete_regenerate( + hass, make_entry +) -> None: + import datetime + + from custom_components.nestquest.children import list_children + from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS + from custom_components.nestquest.dao_instances import QuestInstancesDao + from custom_components.nestquest.dao_presence import PresenceOverridesDao + + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE) + today = datetime.date.today() + end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + dao = QuestInstancesDao(entry.runtime_data.database) + before = await dao.list_by_date_range( + child.id, today.isoformat(), end.isoformat() + ) + assert before + await hass.services.call( + DOMAIN, + SERVICE_CREATE_PRESENCE_OVERRIDE, + { + "child_id": child.id, + "start_date": today.isoformat(), + "end_date": end.isoformat(), + "is_present": False, + }, + ) + assert ( + await dao.list_by_date_range( + child.id, today.isoformat(), end.isoformat() + ) + == [] + ) + override = ( + await PresenceOverridesDao( + entry.runtime_data.database + ).list_by_child_and_range( + child.id, today.isoformat(), end.isoformat() + ) + )[0] + await hass.services.call( + DOMAIN, + SERVICE_DELETE_PRESENCE_OVERRIDE, + {"override_id": override.id}, + ) + after = await dao.list_by_date_range( + child.id, today.isoformat(), end.isoformat() + ) + assert len(after) == len(before) + + async def test_export_history_csv_raises_home_assistant_error( hass, make_entry ) -> None: