feat: regenerate_for_child with child-scoped future delete
This commit is contained in:
@@ -487,6 +487,40 @@ class QuestInstancesDao:
|
|||||||
)
|
)
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|
||||||
|
async def delete_future_uncompleted_for_child(
|
||||||
|
self, child_id: int, cutoff_date: str
|
||||||
|
) -> int:
|
||||||
|
"""Delete the child's open instances at/after cutoff, across every
|
||||||
|
definition.
|
||||||
|
|
||||||
|
Child-scoped counterpart to :meth:`delete_future_uncompleted`,
|
||||||
|
used when a child's presence changes (Feature 09): only instances
|
||||||
|
at or after the cutoff with NO completion event are removed, inside
|
||||||
|
one transaction — an instance that gains a completion event
|
||||||
|
mid-flight cannot be deleted. The cutoff cannot be backdated below
|
||||||
|
today, for the same reason as the definition-scoped method (see its
|
||||||
|
docstring). Any instance with a completion event is never touched,
|
||||||
|
regardless of its date. Returns the number deleted.
|
||||||
|
"""
|
||||||
|
_validate_date(cutoff_date, "cutoff_date")
|
||||||
|
async with _connection_lock(self._database):
|
||||||
|
async with self._database.transaction():
|
||||||
|
# "today" is read under the lock, matching the
|
||||||
|
# definition-scoped method: the DELETE executes on this
|
||||||
|
# date, so a caller queued across midnight cannot delete
|
||||||
|
# instances that became past while it waited.
|
||||||
|
today = datetime.date.today().isoformat()
|
||||||
|
effective_cutoff = max(cutoff_date, today)
|
||||||
|
result = await self._database.execute(
|
||||||
|
"DELETE FROM quest_instances WHERE child_id = ? "
|
||||||
|
"AND due_date >= ? AND NOT EXISTS ("
|
||||||
|
" SELECT 1 FROM completion_events "
|
||||||
|
" WHERE instance_id = quest_instances.id"
|
||||||
|
")",
|
||||||
|
(child_id, effective_cutoff),
|
||||||
|
)
|
||||||
|
return result.rowcount
|
||||||
|
|
||||||
|
|
||||||
class CompletionEventsDao:
|
class CompletionEventsDao:
|
||||||
"""Typed append-only access to the ``completion_events`` table.
|
"""Typed append-only access to the ``completion_events`` table.
|
||||||
|
|||||||
@@ -37,6 +37,23 @@ window), so re-running the materialization over the same range never
|
|||||||
duplicates a row; a tuple whose instance is already completed is SKIPPED
|
duplicates a row; a tuple whose instance is already completed is SKIPPED
|
||||||
(never rewritten, never raised) by
|
(never rewritten, never raised) by
|
||||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
|
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
|
||||||
|
|
||||||
|
Regeneration: a config or presence change must rebuild future instances
|
||||||
|
so they track the new state. :func:`regenerate_for_definition` covers
|
||||||
|
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
|
||||||
|
covers a single child's presence change (Feature 09). Both delete the
|
||||||
|
affected open instances at or after today — never a completed instance,
|
||||||
|
never the past — and re-run the walk over the rolling horizon
|
||||||
|
``[today, today + DEFAULT_HORIZON_DAYS]``.
|
||||||
|
|
||||||
|
HOOK (Feature 09 presence services): the presence business layer that
|
||||||
|
lands in Feature 09 (``set_presence_pattern`` / ``create_presence_override``
|
||||||
|
/ ``delete_presence_override``) MUST call :func:`regenerate_for_child`
|
||||||
|
after each successful presence write so a child's future instances track
|
||||||
|
its presence. This hook is documented, not yet wired: no presence
|
||||||
|
business layer exists yet, and the presence DAO (``dao_presence.py``)
|
||||||
|
must stay free of business rules — never call :func:`regenerate_for_child`
|
||||||
|
from inside the DAO layer.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -200,3 +217,45 @@ async def regenerate_for_definition(
|
|||||||
instances = QuestInstancesDao(database)
|
instances = QuestInstancesDao(database)
|
||||||
await instances.delete_future_uncompleted(definition_id, start_date)
|
await instances.delete_future_uncompleted(definition_id, start_date)
|
||||||
return await materialize(database, start_date, end_date)
|
return await materialize(database, start_date, end_date)
|
||||||
|
|
||||||
|
|
||||||
|
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
|
||||||
|
# child-presence counterpart to ``regenerate_for_definition``. The
|
||||||
|
# presence business layer that lands in Feature 09
|
||||||
|
# (set_presence_pattern / create_presence_override /
|
||||||
|
# delete_presence_override) MUST call ``regenerate_for_child`` after each
|
||||||
|
# successful write, so future instances track a child's changed presence.
|
||||||
|
# It is deliberately NOT wired into ``dao_presence.py``: the DAO layer
|
||||||
|
# stays pure storage (no business rules), and no presence business layer
|
||||||
|
# exists yet to host the call — see the module docstring note below.
|
||||||
|
async def regenerate_for_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""Regenerate a child's future instances after a presence change.
|
||||||
|
|
||||||
|
The Feature 09 presence services (set_presence_pattern /
|
||||||
|
create_presence_override / delete_presence_override) call this after
|
||||||
|
every presence write so a child's future instances track its new
|
||||||
|
presence: the child's open instances at or after today are deleted
|
||||||
|
across ALL its definitions (never a completed instance, never the
|
||||||
|
past) via
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
||||||
|
then the materialization walk re-runs over the rolling horizon
|
||||||
|
``[today, today + DEFAULT_HORIZON_DAYS]`` so the child's
|
||||||
|
now-absent/present dates re-materialize against today's presence.
|
||||||
|
|
||||||
|
"today" is computed the same way the walk computes it —
|
||||||
|
``datetime.date.today()`` — so the delete cutoff and the
|
||||||
|
re-materialization range share one anchor. Returns the number of
|
||||||
|
instances the re-materialization upserted across the whole snapshot
|
||||||
|
(idempotent — other children's and unaffected definitions' tuples
|
||||||
|
refresh in place, never duplicate).
|
||||||
|
"""
|
||||||
|
today = datetime.date.today()
|
||||||
|
start_date = today.isoformat()
|
||||||
|
end_date = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
|
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
await instances.delete_future_uncompleted_for_child(child_id, start_date)
|
||||||
|
return await materialize(database, start_date, end_date)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from custom_components.nestquest.dao_rules import (
|
|||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.materialize import (
|
from custom_components.nestquest.materialize import (
|
||||||
materialize,
|
materialize,
|
||||||
|
regenerate_for_child,
|
||||||
regenerate_for_definition,
|
regenerate_for_definition,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.migrations import apply_migrations
|
||||||
@@ -773,3 +774,116 @@ def test_edit_quest_definition_regenerates_future_instances(tmp_path) -> None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
_with_db(tmp_path, "edit-regenerates.db")(_body)
|
_with_db(tmp_path, "edit-regenerates.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_for_child_rebuilds_horizon_preserves_history(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
from custom_components.nestquest.dao_presence import (
|
||||||
|
PresenceOverridesDao,
|
||||||
|
)
|
||||||
|
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
overrides = PresenceOverridesDao(database)
|
||||||
|
child = await children.create("Ada", NOW)
|
||||||
|
|
||||||
|
today = datetime.date.today()
|
||||||
|
horizon_end = today + datetime.timedelta(days=14)
|
||||||
|
start_iso = today.isoformat()
|
||||||
|
|
||||||
|
created = await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Daily chore",
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
|
||||||
|
[child.id],
|
||||||
|
[("morning", "09:00")],
|
||||||
|
)
|
||||||
|
|
||||||
|
dao = QuestInstancesDao(database)
|
||||||
|
events = CompletionEventsDao(database)
|
||||||
|
|
||||||
|
# Instances across the full horizon: 15 daily rows.
|
||||||
|
await materialize(database, start_iso, horizon_end.isoformat())
|
||||||
|
all_future = await dao.list_by_date_range(
|
||||||
|
child.id, start_iso, horizon_end.isoformat()
|
||||||
|
)
|
||||||
|
assert len(all_future) == 15
|
||||||
|
|
||||||
|
# A completed instance on tomorrow: immutable, must be spared.
|
||||||
|
completed_due = (today + datetime.timedelta(days=1)).isoformat()
|
||||||
|
completed = next(r for r in all_future if r.due_date == completed_due)
|
||||||
|
await events.append(
|
||||||
|
completed.id,
|
||||||
|
child.id,
|
||||||
|
"completed",
|
||||||
|
"user",
|
||||||
|
f"{completed_due}T08:00:00+00:00",
|
||||||
|
True,
|
||||||
|
actor_user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A past instance (yesterday): raw insert bypasses the walk's
|
||||||
|
# no-past guard so the child-scoped delete has history to spare.
|
||||||
|
past_due = (today - datetime.timedelta(days=1)).isoformat()
|
||||||
|
await database.execute(
|
||||||
|
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||||
|
"window, due_date, due_time, generated_at) "
|
||||||
|
"VALUES (?, ?, 'morning', ?, '09:00', ?)",
|
||||||
|
(created.definition.id, child.id, past_due, NOW),
|
||||||
|
)
|
||||||
|
|
||||||
|
# An ABSENT override covering three future dates that had
|
||||||
|
# instances: days 2..4 after today.
|
||||||
|
absent_start = (today + datetime.timedelta(days=2)).isoformat()
|
||||||
|
absent_end = (today + datetime.timedelta(days=4)).isoformat()
|
||||||
|
override = await overrides.create(
|
||||||
|
child.id, absent_start, absent_end, False, note="away"
|
||||||
|
)
|
||||||
|
|
||||||
|
await regenerate_for_child(database, child.id)
|
||||||
|
|
||||||
|
records = await dao.list_by_date_range(
|
||||||
|
child.id, past_due, horizon_end.isoformat()
|
||||||
|
)
|
||||||
|
by_due = {r.due_date: r for r in records}
|
||||||
|
|
||||||
|
# The now-absent days' instances are gone.
|
||||||
|
for offset in range(2, 5):
|
||||||
|
iso = (today + datetime.timedelta(days=offset)).isoformat()
|
||||||
|
assert iso not in by_due
|
||||||
|
|
||||||
|
# The completed instance survived untouched (same physical id).
|
||||||
|
assert by_due[completed_due].id == completed.id
|
||||||
|
|
||||||
|
# The past instance survived.
|
||||||
|
assert past_due in by_due
|
||||||
|
|
||||||
|
# Every non-absent future day still has its instance.
|
||||||
|
for offset in range(15):
|
||||||
|
iso = (today + datetime.timedelta(days=offset)).isoformat()
|
||||||
|
if 2 <= offset <= 4:
|
||||||
|
continue
|
||||||
|
assert iso in by_due
|
||||||
|
|
||||||
|
# Remove the override and regenerate again: the absent days return.
|
||||||
|
await overrides.delete(override.id)
|
||||||
|
await regenerate_for_child(database, child.id)
|
||||||
|
|
||||||
|
records = await dao.list_by_date_range(
|
||||||
|
child.id, past_due, horizon_end.isoformat()
|
||||||
|
)
|
||||||
|
by_due = {r.due_date: r for r in records}
|
||||||
|
|
||||||
|
for offset in range(15):
|
||||||
|
iso = (today + datetime.timedelta(days=offset)).isoformat()
|
||||||
|
assert iso in by_due
|
||||||
|
|
||||||
|
# The completed instance is still the same untouched row, and the
|
||||||
|
# past instance is still there.
|
||||||
|
assert by_due[completed_due].id == completed.id
|
||||||
|
assert by_due[completed_due].due_time == "09:00"
|
||||||
|
assert past_due in by_due
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "regenerate-child.db")(_body)
|
||||||
Reference in New Issue
Block a user