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.
This commit is contained in:
2026-09-18 16:03:42 -04:00
parent 2d30f0ce77
commit b1c42f10d0
4 changed files with 199 additions and 17 deletions
@@ -270,6 +270,15 @@ class PresenceOverridesDao:
) )
return [_override_from_row(row) for row in rows] 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: async def delete(self, override_id: int) -> bool:
"""Remove one override by id; True if a row was removed.""" """Remove one override by id; True if a row was removed."""
result = await self._database.execute( result = await self._database.execute(
+56 -17
View File
@@ -11,8 +11,10 @@ the Feature 07 handler stays the one in ``__init__``.
""" """
from __future__ import annotations from __future__ import annotations
import datetime
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
from zoneinfo import ZoneInfo
import voluptuous as vol import voluptuous as vol
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -22,6 +24,8 @@ from . import children as children_layer
from . import completion as completion_layer from . import completion as completion_layer
from . import quest_definitions as quest_definitions_layer from . import quest_definitions as quest_definitions_layer
from .const import ( from .const import (
CONF_HORIZON_DAYS,
DEFAULT_HORIZON_DAYS,
DOMAIN, DOMAIN,
DOMAIN_SERVICES, DOMAIN_SERVICES,
QUEST_WINDOWS, QUEST_WINDOWS,
@@ -38,6 +42,7 @@ from .const import (
SERVICE_UPDATE_QUEST_DEFINITION, SERVICE_UPDATE_QUEST_DEFINITION,
) )
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .materialize import regenerate_for_child
from .presence import PresenceSchedule from .presence import PresenceSchedule
from .recurrence import ScheduleRule 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) runtime = find_runtime(hass)
if runtime is None: if runtime is None:
raise HomeAssistantError( raise HomeAssistantError(
"NestQuest has no live database; cannot run this service" "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]: def _actor_kwargs(call: Any) -> dict[str, Any]:
@@ -274,7 +290,7 @@ def _complete_quest(
hass: HomeAssistant, find_runtime: FindRuntime hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler: ) -> ServiceHandler:
async def _handler(call: Any) -> None: async def _handler(call: Any) -> None:
database = _require_database(hass, find_runtime) database = _require_runtime(hass, find_runtime).database
await completion_layer.complete_instance( await completion_layer.complete_instance(
database, database,
call.data["instance_id"], call.data["instance_id"],
@@ -288,7 +304,7 @@ def _uncomplete_quest(
hass: HomeAssistant, find_runtime: FindRuntime hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler: ) -> ServiceHandler:
async def _handler(call: Any) -> None: async def _handler(call: Any) -> None:
database = _require_database(hass, find_runtime) database = _require_runtime(hass, find_runtime).database
await completion_layer.uncomplete_instance( await completion_layer.uncomplete_instance(
database, database,
call.data["instance_id"], call.data["instance_id"],
@@ -302,7 +318,7 @@ def _create_quest_definition(
hass: HomeAssistant, find_runtime: FindRuntime hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler: ) -> ServiceHandler:
async def _handler(call: Any) -> None: async def _handler(call: Any) -> None:
database = _require_database(hass, find_runtime) database = _require_runtime(hass, find_runtime).database
data = call.data data = call.data
await quest_definitions_layer.create_quest_definition( await quest_definitions_layer.create_quest_definition(
database, database,
@@ -321,9 +337,9 @@ def _update_quest_definition(
hass: HomeAssistant, find_runtime: FindRuntime hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler: ) -> ServiceHandler:
async def _handler(call: Any) -> None: async def _handler(call: Any) -> None:
database = _require_database(hass, find_runtime) runtime = _require_runtime(hass, find_runtime)
data = call.data data = call.data
kwargs: dict[str, Any] = {} kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
if "title" in data: if "title" in data:
kwargs["title"] = data["title"] kwargs["title"] = data["title"]
if "description" in data: if "description" in data:
@@ -335,7 +351,7 @@ def _update_quest_definition(
if "windows" in data: if "windows" in data:
kwargs["windows"] = data["windows"] kwargs["windows"] = data["windows"]
await quest_definitions_layer.edit_quest_definition( await quest_definitions_layer.edit_quest_definition(
database, data["definition_id"], **kwargs runtime.database, data["definition_id"], **kwargs
) )
return _with_errors(_handler) return _with_errors(_handler)
@@ -345,11 +361,12 @@ def _set_quest_definition_active(
hass: HomeAssistant, find_runtime: FindRuntime hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler: ) -> ServiceHandler:
async def _handler(call: Any) -> None: 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( await quest_definitions_layer.set_quest_definition_active(
database, runtime.database,
call.data["definition_id"], call.data["definition_id"],
call.data["is_active"], call.data["is_active"],
**_generation_kwargs(hass, runtime),
) )
return _with_errors(_handler) return _with_errors(_handler)
@@ -359,7 +376,7 @@ def _set_presence_pattern(
hass: HomeAssistant, find_runtime: FindRuntime hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler: ) -> ServiceHandler:
async def _handler(call: Any) -> None: async def _handler(call: Any) -> None:
database = _require_database(hass, find_runtime) runtime = _require_runtime(hass, find_runtime)
data = call.data data = call.data
schedule = PresenceSchedule( schedule = PresenceSchedule(
data["child_id"], data["child_id"],
@@ -367,12 +384,17 @@ def _set_presence_pattern(
data["anchor_date"], data["anchor_date"],
data["pattern"], data["pattern"],
) )
await PresenceSchedulesDao(database).upsert_by_child( await PresenceSchedulesDao(runtime.database).upsert_by_child(
schedule.child_id, schedule.child_id,
schedule.cycle_length_weeks, schedule.cycle_length_weeks,
schedule.anchor_date.isoformat(), schedule.anchor_date.isoformat(),
schedule.encode(), schedule.encode(),
) )
await regenerate_for_child(
runtime.database,
schedule.child_id,
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler) return _with_errors(_handler)
@@ -381,15 +403,20 @@ def _create_presence_override(
hass: HomeAssistant, find_runtime: FindRuntime hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler: ) -> ServiceHandler:
async def _handler(call: Any) -> None: async def _handler(call: Any) -> None:
database = _require_database(hass, find_runtime) runtime = _require_runtime(hass, find_runtime)
data = call.data data = call.data
await PresenceOverridesDao(database).create( await PresenceOverridesDao(runtime.database).create(
data["child_id"], data["child_id"],
data["start_date"], data["start_date"],
data["end_date"], data["end_date"],
data["is_present"], data["is_present"],
note=data.get("note"), note=data.get("note"),
) )
await regenerate_for_child(
runtime.database,
data["child_id"],
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler) return _with_errors(_handler)
@@ -398,8 +425,20 @@ def _delete_presence_override(
hass: HomeAssistant, find_runtime: FindRuntime hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler: ) -> ServiceHandler:
async def _handler(call: Any) -> None: async def _handler(call: Any) -> None:
database = _require_database(hass, find_runtime) runtime = _require_runtime(hass, find_runtime)
await PresenceOverridesDao(database).delete(call.data["override_id"]) 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) return _with_errors(_handler)
@@ -414,7 +453,7 @@ def _manage_child(
hass: HomeAssistant, find_runtime: FindRuntime hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler: ) -> ServiceHandler:
async def _handler(call: Any) -> None: async def _handler(call: Any) -> None:
database = _require_database(hass, find_runtime) database = _require_runtime(hass, find_runtime).database
data = call.data data = call.data
action = data["action"] action = data["action"]
if action == MANAGE_CHILD_CREATE: if action == MANAGE_CHILD_CREATE:
+14
View File
@@ -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) == [] 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: def test_override_delete_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, schedules, overrides, children, child):
record = await overrides.create( record = await overrides.create(
+120
View File
@@ -15,10 +15,13 @@ from custom_components.nestquest.const import (
DOMAIN, DOMAIN,
DOMAIN_SERVICES, DOMAIN_SERVICES,
SERVICE_COMPLETE_QUEST, SERVICE_COMPLETE_QUEST,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_CREATE_QUEST_DEFINITION, SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV, SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD, SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE, SERVICE_REGENERATE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE, SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_UNCOMPLETE_QUEST, 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( async def test_export_history_csv_raises_home_assistant_error(
hass, make_entry hass, make_entry
) -> None: ) -> None: