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]
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(
+56 -17
View File
@@ -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:
+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) == []
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(
+120
View File
@@ -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: