Feature 15: Domain Core Extraction

Feature 15: Domain Core Extraction (storage, engines/business, settings, no-HA smoke test)
This commit was merged in pull request #146.
This commit is contained in:
2026-09-21 16:01:19 -04:00
73 changed files with 10329 additions and 7723 deletions
+23
View File
@@ -1,5 +1,28 @@
# NestQuest Release Notes
## Unreleased
- **Storage layer extraction (task d0b691d8):** the SQLite storage layer
(schema, migrations, db, store, const, recurrence, DAOs) is bundled
under `custom_components/nestquest/core/`, with the integration's
original module names preserved as re-export shims.
- **Logger namespace change (breaking for logging configuration):** the
migrations runner now logs under
`custom_components.nestquest.core.migrations` instead of
`custom_components.nestquest.migrations`. Users who tuned the old
logger name (e.g. in `logger.yaml` or `configuration.yaml`) must
update the entry to keep that level, as the old name no longer
receives migration log output.
- **Additional logger namespace moves:** the children registry and the
admin allowlist business layers now log under
`custom_components.nestquest.core.children` and
`custom_components.nestquest.core.admin_allowlist` respectively
(moved with the engines/business-layer extraction, task 510c1f78).
The parent `custom_components.nestquest` logger still catches both,
so users who tune the parent rather than the leaf names are
unaffected; only users who pinned a leaf logger name by its old
spelling need to update.
## Version 0.5.0 — 2026-09-18
### Scope
+34 -60
View File
@@ -18,19 +18,16 @@ from homeassistant.helpers.event import async_track_time_change
from .const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
CONF_UPDATE_INTERVAL,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
PLATFORMS,
)
from .core.settings import NestQuestSettings
from .frontend import async_register_frontend
from .coordinator import NestQuestCoordinator
from .db import NestQuestDatabase
from .db import NestQuestDatabase, make_database
from .materialize import materialize as _materialize_run
from .migrations import apply_migrations
from .services import async_deregister_services, async_register_services
@@ -174,7 +171,7 @@ async def _async_open_database(
"""
try:
await _preflight_existing_file(hass, db_path)
database = await NestQuestDatabase(hass).open(db_path)
database = await make_database(hass).open(db_path)
except asyncio.CancelledError:
raise
except sqlite3.DatabaseError as err:
@@ -240,7 +237,7 @@ class NestQuestRuntimeData:
"""Runtime data stored on a NestQuest config entry."""
entry_id: str
options: dict[str, Any]
settings: NestQuestSettings
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
remove_time_change_listener: Callable[[], Any]
@@ -269,38 +266,10 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
return owners
def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
"""Return the (hour, minute) the daily rollover fires at from ``options``.
The options flow validates ``day_rollover_time`` as a strict ``HH:MM``
string, so a stored value never needs re-validation here; a missing key
falls back to :data:`~.const.DEFAULT_DAY_ROLLOVER_TIME`.
"""
rollover = options.get(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME)
hour, _, minute = rollover.partition(":")
return int(hour), int(minute)
def _configured_horizon_days(options: dict[str, Any]) -> int:
"""Return the configured generation horizon, validated, else the default.
The options flow already validates ``horizon_days`` as an integer >= 1,
but a stored ``entry.options`` dict could still carry a partial or
hand-edited value. Re-checking here — and falling back to
:data:`~.const.DEFAULT_HORIZON_DAYS` on a missing, non-int or sub-1 value
— keeps a malformed option from shrinking or exploding the materialized
horizon.
"""
value = options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
return DEFAULT_HORIZON_DAYS
return value
async def _run_horizon_materialization(
hass: HomeAssistant,
database: NestQuestDatabase,
horizon_days: int = DEFAULT_HORIZON_DAYS,
settings: NestQuestSettings,
) -> None:
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
@@ -311,23 +280,22 @@ async def _run_horizon_materialization(
This is the ONE generation path: the startup backfill, the daily rollover
listener and the ``regenerate`` service all funnel through it, so there is
no duplicated materialization logic. ``horizon_days`` sizes the window
and defaults to :data:`~.const.DEFAULT_HORIZON_DAYS`; callers thread the
entry's validated option value through.
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
silent default; every caller already passes the entry's validated
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
window itself comes from :meth:`settings.horizon_window` so the helper is
real and shared (not recomputed inline here).
"""
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
start_date = today.isoformat()
end_date = (
today + datetime.timedelta(days=horizon_days)
).isoformat()
await _materialize_run(database, start_date, end_date, today=today)
start, end = settings.horizon_window(today)
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
def _register_day_rollover_listener(
hass: HomeAssistant,
database: NestQuestDatabase,
options: dict[str, Any],
settings: NestQuestSettings,
) -> Callable[[], Any]:
"""Register the daily rollover listener and return its remover.
@@ -335,16 +303,15 @@ def _register_day_rollover_listener(
first re-materializes the rolling horizon
``[today, today + horizon_days]`` through the shared
:func:`_run_horizon_materialization` path (the entry's configured,
validated ``horizon_days``), then runs the Feature 11 missed sweep
— announcing yesterday's still-open quests as missed AFTER today's
instances exist, watermark-guarded so a re-fire of the listener the
same night announces nothing twice.
validated ``horizon_days`` carried on ``settings``), then runs the
Feature 11 missed sweep — announcing yesterday's still-open quests
as missed AFTER today's instances exist, watermark-guarded so a
re-fire of the listener the same night announces nothing twice.
"""
hour, minute = _day_rollover_hour_minute(options)
horizon_days = _configured_horizon_days(options)
hour, minute = settings.day_rollover_hour_minute
async def _run_materialization(_now: datetime.datetime) -> None:
await _run_horizon_materialization(hass, database, horizon_days)
await _run_horizon_materialization(hass, database, settings)
await run_missed_sweep(hass, database)
return async_track_time_change(
@@ -359,7 +326,9 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
their own; they resolve whichever config entry's runtime data is
currently live at call time, so none of them can hold a stale handle
to a closed connection after another entry unloads. The record also
carries that entry's options (e.g. the configured horizon).
carries that entry's validated ``settings`` (e.g. the configured
horizon) so service handlers read horizon days off it rather than
re-reading HA config.
"""
for runtime_data in hass.data.get(DOMAIN, {}).values():
database = getattr(runtime_data, "database", None)
@@ -379,9 +348,8 @@ def _make_regenerate_handler(hass: HomeAssistant):
"live database; ignoring"
)
return
horizon_days = _configured_horizon_days(runtime_data.options)
await _run_horizon_materialization(
hass, runtime_data.database, horizon_days
hass, runtime_data.database, runtime_data.settings
)
return _regenerate
@@ -495,18 +463,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
context_user_id=context_user_id,
owner_ids=owner_ids,
)
# Build the explicit settings object from entry.options ONCE: the
# horizon materialization and the daily-rollover listener both
# consume it, so core never reads HA config itself. A malformed
# stored option falls back PER FIELD (via from_options_resilient)
# so a single bad sibling option cannot reset the user's
# horizon_days or day_rollover_time — the two fields core
# consumes — to their defaults.
settings = NestQuestSettings.from_options_resilient(entry.options)
remove_update_listener = entry.add_update_listener(_async_update_listener)
remove_time_change_listener = _register_day_rollover_listener(
hass, database, dict(entry.options)
hass, database, settings
)
# Backfill any days the daily listener missed while the integration
# was off (or freshly installed): run the one idempotent walk over
# [today, today + horizon_days] once at setup, using the entry's
# configured horizon. This runs through the SAME path as the daily
# listener and regenerate service.
await _run_horizon_materialization(
hass, database, _configured_horizon_days(dict(entry.options))
)
await _run_horizon_materialization(hass, database, settings)
# The missed sweep runs at startup too (Feature 11): if HA was
# down at the rollover time, the previous night's still-open
# quests are announced as missed now instead of waiting for
@@ -529,7 +503,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id,
options=dict(entry.options),
settings=settings,
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
+7 -149
View File
@@ -1,153 +1,11 @@
"""Business layer for the admin allowlist (Feature 03).
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
Sits between the options flow / Feature 09 service gate and the typed
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
Assistant user IDs — never usernames or display names (feature
guardrail) — and an empty allowlist must fail closed: every function
here refuses to write an empty allowlist, so the owner can never be
accidentally locked out of admin operations by a bad save.
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
in one write are rejected up front — they would silently map to one
row and hide a caller bug. Writes replace the allowlist atomically:
the diff (additions and removals) is computed and applied inside one
transaction under the connection-scoped lock shared with the DAOs, so
a concurrent reader never observes a half-applied sync.
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
when it is EMPTY, so re-running setup never resurrects a deliberately
narrowed list. The setup user (the HA user who completed the config
flow, via the entry context) is added automatically so the owner is
never locked out on first setup; a stored entry-data copy of the list
re-seeds a lost database.
The admin allowlist business layer lives in
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
task 510c1f78); this module re-exports it so existing
``from .admin_allowlist import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
import datetime
import logging
from .dao_children import AdminUsersDao, _connection_lock
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
def _now_stamp() -> str:
"""UTC second-precision stamp, matching the registry's policy."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_user_id(value: object) -> str:
"""Return ``value`` trimmed; raise unless a non-empty string."""
if not isinstance(value, str):
raise ValueError(
f"admin user id must be a string, got {value!r}"
)
trimmed = value.strip()
if not trimmed:
raise ValueError("admin user id must not be empty")
return trimmed
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
"""Return the allowlisted HA user IDs in storage order."""
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
async def set_admin_ids(
database: NestQuestDatabase, user_ids: list[str]
) -> None:
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
Refuses an empty list — an empty allowlist must fail closed, and
the only way to reach one through this API would be removing the
last admin, so that removal is refused here with a clear error.
Refuses non-string, empty, or duplicate ids up front. The diff
against the current allowlist (additions and removals) applies
inside one transaction under the connection lock.
"""
if not isinstance(user_ids, list):
raise ValueError("user_ids must be a list of HA user id strings")
normalized: list[str] = []
for value in user_ids:
normalized.append(_validate_user_id(value))
if len(set(normalized)) != len(normalized):
raise ValueError("user_ids must not contain duplicates")
if not normalized:
raise ValueError(
"refusing to empty the admin allowlist: at least one admin "
"must remain (an empty allowlist fails closed)"
)
dao = AdminUsersDao(database)
async with _connection_lock(database):
async with database.transaction():
current = {row.ha_user_id for row in await dao.list()}
target = set(normalized)
for user_id in sorted(current - target):
await dao.remove(user_id)
for user_id in normalized:
await dao.add(user_id, _now_stamp())
async def is_admin(
database: NestQuestDatabase, user_id: object
) -> bool:
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
True only for an allowlisted user id. Everything else is False:
a non-allowlisted id, an unknown id, a None or non-string value
(a service call with no user context), and an empty allowlist
(nobody is admin rather than everybody). The Feature 09 gate
wraps every admin-only service with this verdict; it never raises
for malformed input — malformed means not admin.
"""
if not isinstance(user_id, str) or not user_id:
return False
return await AdminUsersDao(database).exists(user_id)
async def seed_setup_admin(
database: NestQuestDatabase,
*,
stored_admin_ids: list[str] | None = None,
context_user_id: str | None = None,
owner_ids: list[str] | None = None,
) -> list[str]:
"""Seed an empty allowlist so the owner is never locked out.
Only acts when the allowlist is EMPTY. Preference order: the
stored persisted copy of the list (the options flow's current
selection, then the config flow's original — disaster recovery for
a lost database), then the HA user who completed the config flow
(``context_user_id``), then — when no caller identity survived at
all — every Home Assistant OWNER account (``owner_ids``), so a
first setup whose flow context lost the user id still seeds a
working admin. Returns the allowlist after seeding. Non-empty
allowlists are returned untouched — re-running setup must never
resurrect a deliberately narrowed list.
"""
dao = AdminUsersDao(database)
async with _connection_lock(database):
current = [row.ha_user_id for row in await dao.list()]
if current:
return current
candidates: list[str] = []
if stored_admin_ids is not None:
if not isinstance(stored_admin_ids, list):
raise ValueError(
"stored_admin_ids must be a list of HA user id strings"
)
candidates = [_validate_user_id(value) for value in stored_admin_ids]
elif context_user_id is not None:
candidates = [_validate_user_id(context_user_id)]
elif owner_ids:
if not isinstance(owner_ids, list):
raise ValueError(
"owner_ids must be a list of HA user id strings"
)
candidates = [_validate_user_id(value) for value in owner_ids]
for user_id in candidates:
await dao.add(user_id, _now_stamp())
return [row.ha_user_id for row in await dao.list()]
from .core.admin_allowlist import * # noqa: F401,F403
+7 -276
View File
@@ -1,280 +1,11 @@
"""Business layer for the children registry (Feature 03).
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
Sits between the Feature 09 service gate and the typed
:class:`~.dao_children.ChildrenDao`: callers get validation, name
policies and the duplicate-name warning here, and never talk to the
DAO or SQL directly. Children are never hard-deleted — deactivation
is a separate explicit operation (:func:`set_child_active`), so
completion history keeps its references (feature guardrail).
Name policy: the display name is required and trimmed; an empty or
whitespace-only name is rejected with ValueError. A duplicate display
name is ALLOWED (two children may legitimately share a nickname) but
logged as a warning so an accidental double-add is visible in the HA
log. The duplicate check is case-insensitive and whitespace-normalised
on both sides, because "ada" and "Ada " are the same name to a parent.
Colour and avatar are optional free-form strings, trimmed when present;
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
optional field back to NULL is deliberately rejected: the DAO's update
writes only provided fields (it cannot express NULL), so the business
layer refuses an explicit ``None`` instead of silently ignoring it.
Timestamps follow the module timestamp policy: the business layer
stamps UTC ISO-8601 at second precision so every row it creates is
uniform.
The children registry business layer lives in
:mod:`custom_components.nestquest.core.children` (extracted in
task 510c1f78); this module re-exports it so existing
``from .children import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import datetime
import logging
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
#: in :func:`edit_child`.
_UNSET = object()
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_text(
value: object, field: str, *, required: bool = False
) -> str | None:
"""Validate a free-text field and return it trimmed.
``None`` raises when ``required`` and otherwise returns None. A
non-string value is rejected; an empty result is rejected for
required fields (whitespace-only names are empty to a parent).
"""
if value is None:
if required:
raise ValueError(f"{field} is required")
return None
if not isinstance(value, str):
raise ValueError(f"{field} must be a string, got {value!r}")
trimmed = value.strip()
if required and not trimmed:
raise ValueError(f"{field} is required")
return trimmed
def _validate_sort_order(value: object) -> int:
"""Reject non-int sort orders (bools included) before the DAO."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"sort_order must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def _warn_on_duplicate_name(
database: NestQuestDatabase,
display_name: str,
*,
exclude_child_id: int | None = None,
) -> None:
"""Log a warning when ``display_name`` duplicates an existing child.
Case-insensitive and whitespace-normalised on both sides. The
duplicate is still allowed — this only makes it visible; the log
line names both rows so an accidental double-add can be found.
``exclude_child_id`` removes the child being edited from the scan,
so an edit only warns when the result collides with a DIFFERENT
child's name — including case-only edits, where the self-comparison
would otherwise hide a real duplicate.
"""
lowered = display_name.casefold()
for child in await ChildrenDao(database).list_all():
if exclude_child_id is not None and child.id == exclude_child_id:
continue
if child.display_name.strip().casefold() == lowered:
LOGGER.warning(
"Child display name %r duplicates existing child %d "
"(%r); allowed, but check this was intentional",
display_name,
child.id,
child.display_name,
)
return
async def create_child(
database: NestQuestDatabase,
display_name: str,
*,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int = 0,
) -> ChildRecord:
"""Create a child profile and return the stored record.
The display name is required, trimmed, and rejected when empty.
A duplicate display name (case-insensitive) is allowed but logged
as a warning. The scan and the INSERT run inside the
connection-scoped lock shared with the DAOs, so a concurrent
create of the same normalised name queues behind the first insert
and sees it — the duplicate still warns; it can never slip through
unlogged.
"""
name = _validate_text(display_name, "display_name", required=True)
assert name is not None
colour_value = _validate_text(colour, "colour")
avatar_value = _validate_text(avatar_ref, "avatar_ref")
order = _validate_sort_order(sort_order)
async with _connection_lock(database):
await _warn_on_duplicate_name(database, name)
created = await ChildrenDao(database).create(
name,
_now_stamp(),
colour=colour_value,
avatar_ref=avatar_value,
sort_order=order,
)
return created
async def edit_child(
database: NestQuestDatabase,
child_id: int,
*,
display_name: str | object = _UNSET,
colour: str | None | object = _UNSET,
avatar_ref: str | None | object = _UNSET,
sort_order: int | object = _UNSET,
) -> ChildRecord:
"""Edit a child profile and return the updated record.
Arguments default to the module sentinel ``_UNSET`` meaning "leave
this field alone". An explicitly passed ``None`` for an optional
field raises: the DAO's update writes only provided fields and
cannot express NULL, so a silent no-op would mask the caller's
intent (see the module docstring). A provided display name is
validated like create's; the duplicate scan excludes only the child
being edited, so a case-only edit colliding with another child's
normalised name still warns. Raises ValueError when the child does
not exist.
Atomicity: the existence check, the duplicate scan, the UPDATE and
the readback all run inside the connection-scoped lock shared with
the DAOs, so another edit cannot slip between this call's UPDATE
and its readback — the returned record is exactly the one this
edit produced, not a later writer's.
"""
_validate_child_id(child_id)
async with _connection_lock(database):
dao = ChildrenDao(database)
existing = await dao.get(child_id)
if existing is None:
raise ValueError(f"child {child_id} does not exist")
updates: dict[str, object] = {}
if display_name is not _UNSET:
name = _validate_text(
display_name, "display_name", required=True
)
assert name is not None
await _warn_on_duplicate_name(
database, name, exclude_child_id=child_id
)
updates["display_name"] = name
if colour is not _UNSET:
value = _validate_text(colour, "colour")
if value is None:
raise ValueError(
"clearing colour is not supported; pass a replacement "
"value or omit the field"
)
updates["colour"] = value
if avatar_ref is not _UNSET:
value = _validate_text(avatar_ref, "avatar_ref")
if value is None:
raise ValueError(
"clearing avatar_ref is not supported; pass a "
"replacement value or omit the field"
)
updates["avatar_ref"] = value
if sort_order is not _UNSET:
updates["sort_order"] = _validate_sort_order(sort_order)
if updates:
await dao.update(child_id, **updates)
edited = await dao.get(child_id)
assert edited is not None
return edited
async def set_child_active(
database: NestQuestDatabase, child_id: int, is_active: bool
) -> ChildRecord:
"""Deactivate or reactivate a child; returns the updated record.
Deactivation hides the child from the panel without deleting their
history (feature guardrail); this is the only removal path.
``is_active`` must be a real bool: the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError when the
child does not exist.
Atomicity: existence check, UPDATE and readback run inside the
connection-scoped lock, so a concurrent opposite transition cannot
make this call report the other caller's value.
"""
_validate_child_id(child_id)
async with _connection_lock(database):
dao = ChildrenDao(database)
if await dao.get(child_id) is None:
raise ValueError(f"child {child_id} does not exist")
if not isinstance(is_active, bool):
raise ValueError(
f"is_active must be a real bool, got {is_active!r}"
)
await dao.set_active(child_id, is_active)
updated = await dao.get(child_id)
assert updated is not None
return updated
async def reorder_children(
database: NestQuestDatabase, ordered_ids: list[int]
) -> None:
"""Rewrite the children's panel display order in one transaction.
``ordered_ids`` must be a complete permutation of the children
table — every child id exactly once. A partial list (missing an
existing child), an unknown id, or a duplicate is rejected with
ValueError before any write, so the current order survives a
rejected call. The DAO enforces this inside its own transaction
(check and write are atomic under the connection lock); this
wrapper additionally rejects non-integer ids up front, since
``bool`` and floats would silently compare against stored integer
ids at the SQL layer.
"""
for child_id in ordered_ids:
_validate_child_id(child_id)
await ChildrenDao(database).reorder(ordered_ids)
async def list_children(
database: NestQuestDatabase, *, active_only: bool = False
) -> list[ChildRecord]:
"""Return children in display order (sort_order, then id)."""
dao = ChildrenDao(database)
if active_only:
return await dao.list_active()
return await dao.list_all()
from .core.children import * # noqa: F401,F403
+7 -545
View File
@@ -1,549 +1,11 @@
"""Business layer for the completion event log (Feature 08).
"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package.
Sits between the Feature 09 service gate and the typed
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
validation and the UTC occurred-at stamp here, and never talk to the
DAO or SQL directly. This module is the only Feature-08 caller of
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
instance (no-op if already done) is :func:`complete_instance`;
un-completing (no-op if already open) is :func:`uncomplete_instance`.
Instance current state is derived from the latest event via
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
no status column. ``missed`` is derived for past-due open instances
(``due_date`` before HA-local ``today``) and is never stored as an
event_type.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'``
requires ``actor_child_id`` (the tapped profile) and forbids
``actor_user_id``. Inverted shapes raise a field-naming ValueError
before any write.
``occurred_at`` is stamped UTC ISO-8601 at second precision with an
explicit ``+00:00`` offset, matching the DAO's one accepted shape so
lexicographic range queries stay exact. ``was_on_time`` is required
(True/False) for completed events; un-completions may pass None. There
is no permission check here (that is Feature 09).
The completion business layer lives in
:mod:`custom_components.nestquest.core.completion` (extracted in
task 510c1f78); this module re-exports it so existing
``from .completion import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import asyncio
import datetime
from .dao_children import _CONNECTION_LOCKS, _connection_lock
from .dao_instances import (
EVENT_COMPLETED,
EVENT_UNCOMPLETED,
CompletionEventRecord,
CompletionEventsDao,
QuestInstanceRecord,
QuestInstancesDao,
)
from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
def _stamp_occurred_at(now: datetime.datetime | None) -> str:
"""Return a DAO-accepted UTC stamp for ``now``, or the current time."""
if now is None:
return _now_stamp()
if not isinstance(now, datetime.datetime):
raise ValueError(f"now must be a datetime, got {now!r}")
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
return now.astimezone(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
def _completion_moment(
now: datetime.datetime | None,
today: datetime.date | None,
) -> tuple[datetime.date, datetime.time | None]:
"""Return the HA-local completion date and optional wall-clock time.
``today`` is the HA-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware HA-local datetime; its wall
clock is used as-is and is never converted to UTC. When ``today``
is omitted the date comes from ``now``. When both are omitted the
host calendar date is the Feature 07 fallback — never the UTC
clock. Time-of-day is None unless ``now`` is supplied; comparing
against ``due_time`` requires ``now``.
"""
if now is not None:
if not isinstance(now, datetime.datetime):
raise ValueError(f"now must be a datetime, got {now!r}")
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
if today is not None:
completion_date = today
elif now is not None:
completion_date = now.date()
else:
completion_date = datetime.date.today()
if now is None:
return completion_date, None
return completion_date, now.time()
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
def _derived_state(
instance: QuestInstanceRecord,
latest: CompletionEventRecord | None,
today: datetime.date,
) -> str:
"""Return ``open``, ``done``, or ``missed`` from event plus due date.
A latest completed event is ``done`` even when past due. No events
or a latest uncompleted event is ``open``, or ``missed`` when
``due_date`` is before ``today``. ``missed`` is never an event_type.
"""
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
due_date = datetime.date.fromisoformat(instance.due_date)
if due_date < today:
return "missed"
return "open"
if latest.event_type == EVENT_COMPLETED:
return "done"
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
def derive_state(
instance: QuestInstanceRecord,
latest: CompletionEventRecord | None,
today: datetime.date,
) -> str:
"""Public state derivation for the Feature 10 coordinator.
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
``missed`` from the latest event plus the due date (``missed`` is
derived, never an event_type). ``today`` is the caller-resolved
HA-local date.
"""
return _derived_state(instance, latest, today)
def _was_on_time(
instance: QuestInstanceRecord,
now: datetime.datetime | None,
today: datetime.date | None,
) -> bool:
"""Return whether the completion moment is on time for ``instance``.
On ``due_date`` before ``due_time`` is on time; on ``due_date``
after ``due_time``, or any later date, is late. With no
``due_time``, on time if the completion date equals ``due_date``
(or is earlier); late if after ``due_date``. Same-day
``due_time`` comparison requires ``now``; UTC time-of-day is never
substituted.
"""
completion_date, completion_time = _completion_moment(now, today)
due_date = datetime.date.fromisoformat(instance.due_date)
if completion_date < due_date:
return True
if completion_date > due_date:
return False
if instance.due_time is None:
return True
if completion_time is None:
raise ValueError("now is required to compare due_time")
due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time()
return completion_time <= due_time
class _TaskReentrantLock:
"""Shared connection lock the owning task may re-enter."""
def __init__(self, inner: asyncio.Lock) -> None:
self._inner = inner
self._task: asyncio.Task | None = None
self._depth = 0
def locked(self) -> bool:
return self._inner.locked()
async def acquire(self) -> bool:
task = asyncio.current_task()
if self._task is task:
self._depth += 1
return True
await self._inner.acquire()
self._task = task
self._depth = 1
return True
def release(self) -> None:
if self._task is not asyncio.current_task():
raise RuntimeError("lock released by a non-owner")
self._depth -= 1
if self._depth == 0:
self._task = None
self._inner.release()
async def __aenter__(self) -> _TaskReentrantLock:
await self.acquire()
return self
async def __aexit__(self, *exc: object) -> None:
self.release()
def _reentrant_connection_lock(database) -> _TaskReentrantLock:
"""Return the shared connection lock, made task-reentrant.
``CompletionEventsDao.append`` also acquires this lock, so
check+append in :func:`complete_instance` and
:func:`uncomplete_instance` must re-enter on the same task. The
wrapper is stored in the shared dict so the DAO sees the same
object.
"""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _CONNECTION_LOCKS.get(key)
if isinstance(lock, _TaskReentrantLock):
return lock
wrapped = _TaskReentrantLock(
lock if lock is not None else asyncio.Lock()
)
_CONNECTION_LOCKS[key] = wrapped
return wrapped
def _validate_int_id(value: object, field: str) -> int:
"""Reject non-int ids (bools included) before any lookup."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{field} must be an integer, got {value!r}")
return value
def _validate_event_type(value: object) -> str:
"""Return a schema-legal event type, or raise naming the field."""
if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {value!r}"
)
return value
def _validate_actor(
actor_source: object,
actor_user_id: object,
actor_child_id: object,
) -> tuple[str, str | None, int | None]:
"""Return a schema-legal actor triple, or raise naming the field.
``'user'`` requires a non-empty ``actor_user_id`` string and forbids
``actor_child_id``. ``'panel'`` requires an integer
``actor_child_id`` and forbids ``actor_user_id``. Any other
``actor_source`` (including ``'service'``) is rejected.
"""
if actor_source not in ("user", "panel"):
raise ValueError(
f"actor_source must be 'user' or 'panel', got {actor_source!r}"
)
if actor_source == "user":
if not isinstance(actor_user_id, str) or not actor_user_id.strip():
raise ValueError("actor_source 'user' requires actor_user_id")
if actor_child_id is not None:
raise ValueError(
"actor_source 'user' must not carry actor_child_id"
)
return "user", actor_user_id.strip(), None
if actor_user_id is not None:
raise ValueError("actor_source 'panel' must not carry actor_user_id")
if actor_child_id is None:
raise ValueError(
"actor_source 'panel' requires actor_child_id"
)
return (
"panel",
None,
_validate_int_id(actor_child_id, "actor_child_id"),
)
def _validate_was_on_time(
event_type: str, was_on_time: object
) -> bool | None:
"""Return a real bool or None; completed events require True/False."""
if was_on_time is not None and type(was_on_time) is not bool:
raise ValueError(
f"was_on_time must be True, False or None, got {was_on_time!r}"
)
if event_type == EVENT_COMPLETED and was_on_time is None:
raise ValueError(
"a completed event requires was_on_time (True/False)"
)
return was_on_time
async def append_event(
database: NestQuestDatabase,
instance_id: int,
child_id: int,
event_type: str,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
was_on_time: bool | None = None,
now: datetime.datetime | None = None,
) -> CompletionEventRecord:
"""Append one completion or un-completion event and return the row.
Validates actor shapes and the completed-event on-time requirement
before calling :meth:`CompletionEventsDao.append`. ``occurred_at``
is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
child_id = _validate_int_id(child_id, "child_id")
event_type = _validate_event_type(event_type)
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
was_on_time = _validate_was_on_time(event_type, was_on_time)
occurred_at = _stamp_occurred_at(now)
return await CompletionEventsDao(database).append(
instance_id,
child_id,
event_type,
actor_source,
occurred_at,
was_on_time,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
)
async def instance_state(
database: NestQuestDatabase,
instance_id: int,
*,
today: datetime.date | None = None,
) -> str:
"""Return ``open``, ``done``, or ``missed`` from the latest event.
No events, or a latest event of uncompleted, is ``open`` unless
``due_date`` is before ``today`` (HA-local, threaded), in which
case it is ``missed``. A latest event of completed is ``done``
even when past due. ``missed`` is never stored as an event_type.
Unknown ``instance_id`` raises ValueError naming the field.
Existence and the latest event are read inside the connection lock
so the answer is one snapshot.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
today_date = _resolve_today(today)
async with _connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
return _derived_state(instance, latest, today_date)
class CompletionResult(str):
"""The derived instance state, carrying the append verdict.
A ``str`` subclass so every existing ``result == "done"`` contract
keeps working; ``appended`` reports whether THIS call actually
appended an event (decided under the same lock as the append), so
a caller — the Feature 09 service path firing bus events —
announces a transition exactly once even under a concurrent
duplicate call where the loser's write is a no-op. A completion
also carries the ``was_on_time`` verdict computed under that same
lock, so the event payload never re-reads mutable latest state a
racing reversal could have replaced.
"""
appended: bool
was_on_time: bool | None
def __new__(
cls,
state: str,
*,
appended: bool,
was_on_time: bool | None = None,
) -> "CompletionResult":
obj = super().__new__(cls, state)
obj.appended = appended
obj.was_on_time = was_on_time
return obj
async def complete_instance(
database: NestQuestDatabase,
instance_id: int,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> CompletionResult:
"""Append a completed event unless the instance is already done.
Returns the derived state ``done``; ``appended`` reports whether
THIS call appended (False on an already-done no-op). Unknown
``instance_id`` raises ValueError naming the field. Existence,
latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append.
``was_on_time`` is computed from the HA-local completion moment
(threaded ``now`` / ``today``) against the instance ``due_date``
and optional ``due_time``. ``now`` is the timezone-aware HA-local
datetime; its wall clock is never converted to UTC. Same-day
``due_time`` comparison requires ``now``.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
async with _reentrant_connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
if latest is not None and latest.event_type == EVENT_COMPLETED:
return CompletionResult("done", appended=False)
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
# Computed under the completion lock and carried on the result:
# the bus event must report THIS completion's timing, never a
# re-read of the latest event a racing reversal could replace.
on_time = _was_on_time(instance, now, today)
await append_event(
database,
instance.id,
instance.child_id,
EVENT_COMPLETED,
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=on_time,
now=now,
)
return CompletionResult("done", appended=True, was_on_time=on_time)
async def uncomplete_instance(
database: NestQuestDatabase,
instance_id: int,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> CompletionResult:
"""Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``, or ``missed`` when the instance
is past due (``due_date`` before HA-local ``today``); ``appended``
reports whether THIS call appended (False on an already-open
no-op). Unknown ``instance_id`` raises ValueError naming the
field. Existence, latest-event, and the append run under one
connection lock so a concurrent uncomplete cannot double-append.
The original completed row is not modified.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
today_date = _resolve_today(today)
async with _reentrant_connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
return CompletionResult(
_derived_state(instance, latest, today_date),
appended=False,
)
if latest.event_type != EVENT_COMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
latest = await append_event(
database,
instance.id,
instance.child_id,
EVENT_UNCOMPLETED,
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=None,
now=now,
)
return CompletionResult(
_derived_state(instance, latest, today_date), appended=True
)
async def list_missed_for_child(
database: NestQuestDatabase,
child_id: int,
range_start: str,
range_end: str,
*,
today: datetime.date | None = None,
) -> list[QuestInstanceRecord]:
"""Return the child's missed instances due in the closed date range.
Missed means the latest event state is open and ``due_date`` is
before ``today`` (HA-local, threaded). Uses
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
Never appends a missed event. Instances and latest events are
read inside the connection lock so the list is one snapshot.
"""
child_id = _validate_int_id(child_id, "child_id")
today_date = _resolve_today(today)
async with _connection_lock(database):
instances = await QuestInstancesDao(database).list_by_date_range(
child_id, range_start, range_end
)
events = CompletionEventsDao(database)
missed: list[QuestInstanceRecord] = []
for instance in instances:
latest = await events.get_latest_for_instance(instance.id)
if _derived_state(instance, latest, today_date) == "missed":
missed.append(instance)
return missed
from .core.completion import * # noqa: F401,F403
+8 -117
View File
@@ -1,119 +1,10 @@
"""Constants for the NestQuest integration."""
"""Re-export of :mod:`custom_components.nestquest.core.const` for the integration package.
The canonical constants live in :mod:`custom_components.nestquest.core.const` (the storage layer
extracted in task d0b691d8); this module re-exports them so existing
``from .const import ...`` references inside the integration keep
working without touching every import line.
"""
from __future__ import annotations
import logging
DOMAIN = "nestquest"
LOGGER_NAME = "custom_components.nestquest"
LOGGER = logging.getLogger(LOGGER_NAME)
# Platforms set up per config entry (Feature 10): the per-child day
# sensors and binary sensors.
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
# panel and admin cards listen for these; Feature 10 fires completed,
# uncompleted, and child_day_complete from the service paths, and
# Feature 11's nightly sweep fires quest_missed.
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
EVENT_QUEST_MISSED = "nestquest_quest_missed"
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
#: plus the existing Feature 07 regenerate service).
SERVICE_COMPLETE_QUEST = "complete_quest"
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
SERVICE_MANAGE_CHILD = "manage_child"
SERVICE_REGENERATE = "regenerate"
DOMAIN_SERVICES: tuple[str, ...] = (
SERVICE_COMPLETE_QUEST,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_UPDATE_QUEST_DEFINITION,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
)
SQLITE_DB_FILENAME = "nestquest.db"
# Configuration keys and default values
CONF_HORIZON_DAYS = "horizon_days"
DEFAULT_HORIZON_DAYS = 14
CONF_DAY_ROLLOVER_TIME = "day_rollover_time"
DEFAULT_DAY_ROLLOVER_TIME = "00:00"
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
DEFAULT_PANEL_IDLE_TIMEOUT = 300
# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only
# services. Stored as IDs, never usernames (feature guardrail). The
# database allowlist is authoritative; the config entry carries the same
# list as a disaster-recovery copy so a lost database can be re-seeded.
CONF_ADMIN_USER_IDS = "admin_user_ids"
# Shared coordinator refresh interval (Feature 10), in SECONDS. Every
# entity reads one snapshot per refresh; completions push an immediate
# refresh so this poll is only the fallback cadence.
CONF_UPDATE_INTERVAL = "update_interval"
DEFAULT_UPDATE_INTERVAL = 300
MIN_UPDATE_INTERVAL = 30
# Notification automations (Feature 11). The notify target is a free-text
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
# household fills in — never a hard-coded personal target; the times are
# strict HH:MM local; each of the four automations has an independent
# enable toggle. Per the owner-settled resolution the end-of-day report
# defaults to 20:00 — deliberately BEFORE the midnight missed sweep —
# and never defaults to the rollover time.
CONF_NOTIFY_TARGET = "notify_target"
CONF_MORNING_SUMMARY_TIME = "morning_summary_time"
DEFAULT_MORNING_SUMMARY_TIME = "08:00"
CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time"
DEFAULT_AFTERNOON_REMINDER_TIME = "15:00"
CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time"
DEFAULT_END_OF_DAY_REPORT_TIME = "20:00"
CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled"
CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True
# Quest windows (D-008): a definition may declare several day windows;
# each produces its own instance per day and groups the panel's Quest
# Log into three columns. The clock ranges are stored here so they stay
# configurable later. Boundaries: a window's end equals the next
# window's start; classification (Feature 07 materialisation) treats a
# due time exactly on the boundary as belonging to the LATER window, so
# 12:00 is afternoon (not morning) and 17:00 is evening (not
# afternoon). Times after 21:00 belong to no window.
WINDOW_MORNING = "morning"
WINDOW_AFTERNOON = "afternoon"
WINDOW_EVENING = "evening"
QUEST_WINDOWS: tuple[str, ...] = (
WINDOW_MORNING,
WINDOW_AFTERNOON,
WINDOW_EVENING,
)
#: Inclusive "HH:MM" bounds per window, in 24-hour local time.
WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = {
WINDOW_MORNING: ("00:00", "11:59"),
WINDOW_AFTERNOON: ("12:00", "17:00"),
WINDOW_EVENING: ("17:00", "21:00"),
}
from .core.const import * # noqa: F401,F403
@@ -0,0 +1,11 @@
"""NestQuest storage layer.
A self-contained, Home-Assistant-free package holding the NestQuest
SQLite storage layer: schema DDL, the versioned migration runner, the
async connection wrapper, the path resolver, the domain constants and
recurrence model, and the typed DAOs. Nothing in this package imports
``homeassistant``; the integration wires it into HA at its own layer
(constructing :class:`custom_components.nestquest.core.db.NestQuestDatabase`
with an executor adapter backed by ``hass.async_add_executor_job``).
"""
from __future__ import annotations
@@ -0,0 +1,153 @@
"""Business layer for the admin allowlist (Feature 03).
Sits between the options flow / Feature 09 service gate and the typed
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
Assistant user IDs — never usernames or display names (feature
guardrail) — and an empty allowlist must fail closed: every function
here refuses to write an empty allowlist, so the owner can never be
accidentally locked out of admin operations by a bad save.
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
in one write are rejected up front — they would silently map to one
row and hide a caller bug. Writes replace the allowlist atomically:
the diff (additions and removals) is computed and applied inside one
transaction under the connection-scoped lock shared with the DAOs, so
a concurrent reader never observes a half-applied sync.
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
when it is EMPTY, so re-running setup never resurrects a deliberately
narrowed list. The setup user (the HA user who completed the config
flow, via the entry context) is added automatically so the owner is
never locked out on first setup; a stored entry-data copy of the list
re-seeds a lost database.
"""
from __future__ import annotations
import datetime
import logging
from .dao_children import AdminUsersDao, _connection_lock
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
def _now_stamp() -> str:
"""UTC second-precision stamp, matching the registry's policy."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_user_id(value: object) -> str:
"""Return ``value`` trimmed; raise unless a non-empty string."""
if not isinstance(value, str):
raise ValueError(
f"admin user id must be a string, got {value!r}"
)
trimmed = value.strip()
if not trimmed:
raise ValueError("admin user id must not be empty")
return trimmed
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
"""Return the allowlisted HA user IDs in storage order."""
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
async def set_admin_ids(
database: NestQuestDatabase, user_ids: list[str]
) -> None:
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
Refuses an empty list — an empty allowlist must fail closed, and
the only way to reach one through this API would be removing the
last admin, so that removal is refused here with a clear error.
Refuses non-string, empty, or duplicate ids up front. The diff
against the current allowlist (additions and removals) applies
inside one transaction under the connection lock.
"""
if not isinstance(user_ids, list):
raise ValueError("user_ids must be a list of HA user id strings")
normalized: list[str] = []
for value in user_ids:
normalized.append(_validate_user_id(value))
if len(set(normalized)) != len(normalized):
raise ValueError("user_ids must not contain duplicates")
if not normalized:
raise ValueError(
"refusing to empty the admin allowlist: at least one admin "
"must remain (an empty allowlist fails closed)"
)
dao = AdminUsersDao(database)
async with _connection_lock(database):
async with database.transaction():
current = {row.ha_user_id for row in await dao.list()}
target = set(normalized)
for user_id in sorted(current - target):
await dao.remove(user_id)
for user_id in normalized:
await dao.add(user_id, _now_stamp())
async def is_admin(
database: NestQuestDatabase, user_id: object
) -> bool:
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
True only for an allowlisted user id. Everything else is False:
a non-allowlisted id, an unknown id, a None or non-string value
(a service call with no user context), and an empty allowlist
(nobody is admin rather than everybody). The Feature 09 gate
wraps every admin-only service with this verdict; it never raises
for malformed input — malformed means not admin.
"""
if not isinstance(user_id, str) or not user_id:
return False
return await AdminUsersDao(database).exists(user_id)
async def seed_setup_admin(
database: NestQuestDatabase,
*,
stored_admin_ids: list[str] | None = None,
context_user_id: str | None = None,
owner_ids: list[str] | None = None,
) -> list[str]:
"""Seed an empty allowlist so the owner is never locked out.
Only acts when the allowlist is EMPTY. Preference order: the
stored persisted copy of the list (the options flow's current
selection, then the config flow's original — disaster recovery for
a lost database), then the HA user who completed the config flow
(``context_user_id``), then — when no caller identity survived at
all — every Home Assistant OWNER account (``owner_ids``), so a
first setup whose flow context lost the user id still seeds a
working admin. Returns the allowlist after seeding. Non-empty
allowlists are returned untouched — re-running setup must never
resurrect a deliberately narrowed list.
"""
dao = AdminUsersDao(database)
async with _connection_lock(database):
current = [row.ha_user_id for row in await dao.list()]
if current:
return current
candidates: list[str] = []
if stored_admin_ids is not None:
if not isinstance(stored_admin_ids, list):
raise ValueError(
"stored_admin_ids must be a list of HA user id strings"
)
candidates = [_validate_user_id(value) for value in stored_admin_ids]
elif context_user_id is not None:
candidates = [_validate_user_id(context_user_id)]
elif owner_ids:
if not isinstance(owner_ids, list):
raise ValueError(
"owner_ids must be a list of HA user id strings"
)
candidates = [_validate_user_id(value) for value in owner_ids]
for user_id in candidates:
await dao.add(user_id, _now_stamp())
return [row.ha_user_id for row in await dao.list()]
@@ -0,0 +1,280 @@
"""Business layer for the children registry (Feature 03).
Sits between the Feature 09 service gate and the typed
:class:`~.dao_children.ChildrenDao`: callers get validation, name
policies and the duplicate-name warning here, and never talk to the
DAO or SQL directly. Children are never hard-deleted — deactivation
is a separate explicit operation (:func:`set_child_active`), so
completion history keeps its references (feature guardrail).
Name policy: the display name is required and trimmed; an empty or
whitespace-only name is rejected with ValueError. A duplicate display
name is ALLOWED (two children may legitimately share a nickname) but
logged as a warning so an accidental double-add is visible in the HA
log. The duplicate check is case-insensitive and whitespace-normalised
on both sides, because "ada" and "Ada " are the same name to a parent.
Colour and avatar are optional free-form strings, trimmed when present;
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
optional field back to NULL is deliberately rejected: the DAO's update
writes only provided fields (it cannot express NULL), so the business
layer refuses an explicit ``None`` instead of silently ignoring it.
Timestamps follow the module timestamp policy: the business layer
stamps UTC ISO-8601 at second precision so every row it creates is
uniform.
"""
from __future__ import annotations
import datetime
import logging
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
#: in :func:`edit_child`.
_UNSET = object()
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_text(
value: object, field: str, *, required: bool = False
) -> str | None:
"""Validate a free-text field and return it trimmed.
``None`` raises when ``required`` and otherwise returns None. A
non-string value is rejected; an empty result is rejected for
required fields (whitespace-only names are empty to a parent).
"""
if value is None:
if required:
raise ValueError(f"{field} is required")
return None
if not isinstance(value, str):
raise ValueError(f"{field} must be a string, got {value!r}")
trimmed = value.strip()
if required and not trimmed:
raise ValueError(f"{field} is required")
return trimmed
def _validate_sort_order(value: object) -> int:
"""Reject non-int sort orders (bools included) before the DAO."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"sort_order must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def _warn_on_duplicate_name(
database: NestQuestDatabase,
display_name: str,
*,
exclude_child_id: int | None = None,
) -> None:
"""Log a warning when ``display_name`` duplicates an existing child.
Case-insensitive and whitespace-normalised on both sides. The
duplicate is still allowed — this only makes it visible; the log
line names both rows so an accidental double-add can be found.
``exclude_child_id`` removes the child being edited from the scan,
so an edit only warns when the result collides with a DIFFERENT
child's name — including case-only edits, where the self-comparison
would otherwise hide a real duplicate.
"""
lowered = display_name.casefold()
for child in await ChildrenDao(database).list_all():
if exclude_child_id is not None and child.id == exclude_child_id:
continue
if child.display_name.strip().casefold() == lowered:
LOGGER.warning(
"Child display name %r duplicates existing child %d "
"(%r); allowed, but check this was intentional",
display_name,
child.id,
child.display_name,
)
return
async def create_child(
database: NestQuestDatabase,
display_name: str,
*,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int = 0,
) -> ChildRecord:
"""Create a child profile and return the stored record.
The display name is required, trimmed, and rejected when empty.
A duplicate display name (case-insensitive) is allowed but logged
as a warning. The scan and the INSERT run inside the
connection-scoped lock shared with the DAOs, so a concurrent
create of the same normalised name queues behind the first insert
and sees it — the duplicate still warns; it can never slip through
unlogged.
"""
name = _validate_text(display_name, "display_name", required=True)
assert name is not None
colour_value = _validate_text(colour, "colour")
avatar_value = _validate_text(avatar_ref, "avatar_ref")
order = _validate_sort_order(sort_order)
async with _connection_lock(database):
await _warn_on_duplicate_name(database, name)
created = await ChildrenDao(database).create(
name,
_now_stamp(),
colour=colour_value,
avatar_ref=avatar_value,
sort_order=order,
)
return created
async def edit_child(
database: NestQuestDatabase,
child_id: int,
*,
display_name: str | object = _UNSET,
colour: str | None | object = _UNSET,
avatar_ref: str | None | object = _UNSET,
sort_order: int | object = _UNSET,
) -> ChildRecord:
"""Edit a child profile and return the updated record.
Arguments default to the module sentinel ``_UNSET`` meaning "leave
this field alone". An explicitly passed ``None`` for an optional
field raises: the DAO's update writes only provided fields and
cannot express NULL, so a silent no-op would mask the caller's
intent (see the module docstring). A provided display name is
validated like create's; the duplicate scan excludes only the child
being edited, so a case-only edit colliding with another child's
normalised name still warns. Raises ValueError when the child does
not exist.
Atomicity: the existence check, the duplicate scan, the UPDATE and
the readback all run inside the connection-scoped lock shared with
the DAOs, so another edit cannot slip between this call's UPDATE
and its readback — the returned record is exactly the one this
edit produced, not a later writer's.
"""
_validate_child_id(child_id)
async with _connection_lock(database):
dao = ChildrenDao(database)
existing = await dao.get(child_id)
if existing is None:
raise ValueError(f"child {child_id} does not exist")
updates: dict[str, object] = {}
if display_name is not _UNSET:
name = _validate_text(
display_name, "display_name", required=True
)
assert name is not None
await _warn_on_duplicate_name(
database, name, exclude_child_id=child_id
)
updates["display_name"] = name
if colour is not _UNSET:
value = _validate_text(colour, "colour")
if value is None:
raise ValueError(
"clearing colour is not supported; pass a replacement "
"value or omit the field"
)
updates["colour"] = value
if avatar_ref is not _UNSET:
value = _validate_text(avatar_ref, "avatar_ref")
if value is None:
raise ValueError(
"clearing avatar_ref is not supported; pass a "
"replacement value or omit the field"
)
updates["avatar_ref"] = value
if sort_order is not _UNSET:
updates["sort_order"] = _validate_sort_order(sort_order)
if updates:
await dao.update(child_id, **updates)
edited = await dao.get(child_id)
assert edited is not None
return edited
async def set_child_active(
database: NestQuestDatabase, child_id: int, is_active: bool
) -> ChildRecord:
"""Deactivate or reactivate a child; returns the updated record.
Deactivation hides the child from the panel without deleting their
history (feature guardrail); this is the only removal path.
``is_active`` must be a real bool: the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError when the
child does not exist.
Atomicity: existence check, UPDATE and readback run inside the
connection-scoped lock, so a concurrent opposite transition cannot
make this call report the other caller's value.
"""
_validate_child_id(child_id)
async with _connection_lock(database):
dao = ChildrenDao(database)
if await dao.get(child_id) is None:
raise ValueError(f"child {child_id} does not exist")
if not isinstance(is_active, bool):
raise ValueError(
f"is_active must be a real bool, got {is_active!r}"
)
await dao.set_active(child_id, is_active)
updated = await dao.get(child_id)
assert updated is not None
return updated
async def reorder_children(
database: NestQuestDatabase, ordered_ids: list[int]
) -> None:
"""Rewrite the children's panel display order in one transaction.
``ordered_ids`` must be a complete permutation of the children
table — every child id exactly once. A partial list (missing an
existing child), an unknown id, or a duplicate is rejected with
ValueError before any write, so the current order survives a
rejected call. The DAO enforces this inside its own transaction
(check and write are atomic under the connection lock); this
wrapper additionally rejects non-integer ids up front, since
``bool`` and floats would silently compare against stored integer
ids at the SQL layer.
"""
for child_id in ordered_ids:
_validate_child_id(child_id)
await ChildrenDao(database).reorder(ordered_ids)
async def list_children(
database: NestQuestDatabase, *, active_only: bool = False
) -> list[ChildRecord]:
"""Return children in display order (sort_order, then id)."""
dao = ChildrenDao(database)
if active_only:
return await dao.list_active()
return await dao.list_all()
@@ -0,0 +1,549 @@
"""Business layer for the completion event log (Feature 08).
Sits between the Feature 09 service gate and the typed
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
validation and the UTC occurred-at stamp here, and never talk to the
DAO or SQL directly. This module is the only Feature-08 caller of
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
instance (no-op if already done) is :func:`complete_instance`;
un-completing (no-op if already open) is :func:`uncomplete_instance`.
Instance current state is derived from the latest event via
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
no status column. ``missed`` is derived for past-due open instances
(``due_date`` before HA-local ``today``) and is never stored as an
event_type.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'``
requires ``actor_child_id`` (the tapped profile) and forbids
``actor_user_id``. Inverted shapes raise a field-naming ValueError
before any write.
``occurred_at`` is stamped UTC ISO-8601 at second precision with an
explicit ``+00:00`` offset, matching the DAO's one accepted shape so
lexicographic range queries stay exact. ``was_on_time`` is required
(True/False) for completed events; un-completions may pass None. There
is no permission check here (that is Feature 09).
"""
from __future__ import annotations
import asyncio
import datetime
from .dao_children import _CONNECTION_LOCKS, _connection_lock
from .dao_instances import (
EVENT_COMPLETED,
EVENT_UNCOMPLETED,
CompletionEventRecord,
CompletionEventsDao,
QuestInstanceRecord,
QuestInstancesDao,
)
from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
def _stamp_occurred_at(now: datetime.datetime | None) -> str:
"""Return a DAO-accepted UTC stamp for ``now``, or the current time."""
if now is None:
return _now_stamp()
if not isinstance(now, datetime.datetime):
raise ValueError(f"now must be a datetime, got {now!r}")
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
return now.astimezone(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
def _completion_moment(
now: datetime.datetime | None,
today: datetime.date | None,
) -> tuple[datetime.date, datetime.time | None]:
"""Return the HA-local completion date and optional wall-clock time.
``today`` is the HA-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware HA-local datetime; its wall
clock is used as-is and is never converted to UTC. When ``today``
is omitted the date comes from ``now``. When both are omitted the
host calendar date is the Feature 07 fallback — never the UTC
clock. Time-of-day is None unless ``now`` is supplied; comparing
against ``due_time`` requires ``now``.
"""
if now is not None:
if not isinstance(now, datetime.datetime):
raise ValueError(f"now must be a datetime, got {now!r}")
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
if today is not None:
completion_date = today
elif now is not None:
completion_date = now.date()
else:
completion_date = datetime.date.today()
if now is None:
return completion_date, None
return completion_date, now.time()
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
def _derived_state(
instance: QuestInstanceRecord,
latest: CompletionEventRecord | None,
today: datetime.date,
) -> str:
"""Return ``open``, ``done``, or ``missed`` from event plus due date.
A latest completed event is ``done`` even when past due. No events
or a latest uncompleted event is ``open``, or ``missed`` when
``due_date`` is before ``today``. ``missed`` is never an event_type.
"""
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
due_date = datetime.date.fromisoformat(instance.due_date)
if due_date < today:
return "missed"
return "open"
if latest.event_type == EVENT_COMPLETED:
return "done"
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
def derive_state(
instance: QuestInstanceRecord,
latest: CompletionEventRecord | None,
today: datetime.date,
) -> str:
"""Public state derivation for the Feature 10 coordinator.
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
``missed`` from the latest event plus the due date (``missed`` is
derived, never an event_type). ``today`` is the caller-resolved
HA-local date.
"""
return _derived_state(instance, latest, today)
def _was_on_time(
instance: QuestInstanceRecord,
now: datetime.datetime | None,
today: datetime.date | None,
) -> bool:
"""Return whether the completion moment is on time for ``instance``.
On ``due_date`` before ``due_time`` is on time; on ``due_date``
after ``due_time``, or any later date, is late. With no
``due_time``, on time if the completion date equals ``due_date``
(or is earlier); late if after ``due_date``. Same-day
``due_time`` comparison requires ``now``; UTC time-of-day is never
substituted.
"""
completion_date, completion_time = _completion_moment(now, today)
due_date = datetime.date.fromisoformat(instance.due_date)
if completion_date < due_date:
return True
if completion_date > due_date:
return False
if instance.due_time is None:
return True
if completion_time is None:
raise ValueError("now is required to compare due_time")
due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time()
return completion_time <= due_time
class _TaskReentrantLock:
"""Shared connection lock the owning task may re-enter."""
def __init__(self, inner: asyncio.Lock) -> None:
self._inner = inner
self._task: asyncio.Task | None = None
self._depth = 0
def locked(self) -> bool:
return self._inner.locked()
async def acquire(self) -> bool:
task = asyncio.current_task()
if self._task is task:
self._depth += 1
return True
await self._inner.acquire()
self._task = task
self._depth = 1
return True
def release(self) -> None:
if self._task is not asyncio.current_task():
raise RuntimeError("lock released by a non-owner")
self._depth -= 1
if self._depth == 0:
self._task = None
self._inner.release()
async def __aenter__(self) -> _TaskReentrantLock:
await self.acquire()
return self
async def __aexit__(self, *exc: object) -> None:
self.release()
def _reentrant_connection_lock(database) -> _TaskReentrantLock:
"""Return the shared connection lock, made task-reentrant.
``CompletionEventsDao.append`` also acquires this lock, so
check+append in :func:`complete_instance` and
:func:`uncomplete_instance` must re-enter on the same task. The
wrapper is stored in the shared dict so the DAO sees the same
object.
"""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _CONNECTION_LOCKS.get(key)
if isinstance(lock, _TaskReentrantLock):
return lock
wrapped = _TaskReentrantLock(
lock if lock is not None else asyncio.Lock()
)
_CONNECTION_LOCKS[key] = wrapped
return wrapped
def _validate_int_id(value: object, field: str) -> int:
"""Reject non-int ids (bools included) before any lookup."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{field} must be an integer, got {value!r}")
return value
def _validate_event_type(value: object) -> str:
"""Return a schema-legal event type, or raise naming the field."""
if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {value!r}"
)
return value
def _validate_actor(
actor_source: object,
actor_user_id: object,
actor_child_id: object,
) -> tuple[str, str | None, int | None]:
"""Return a schema-legal actor triple, or raise naming the field.
``'user'`` requires a non-empty ``actor_user_id`` string and forbids
``actor_child_id``. ``'panel'`` requires an integer
``actor_child_id`` and forbids ``actor_user_id``. Any other
``actor_source`` (including ``'service'``) is rejected.
"""
if actor_source not in ("user", "panel"):
raise ValueError(
f"actor_source must be 'user' or 'panel', got {actor_source!r}"
)
if actor_source == "user":
if not isinstance(actor_user_id, str) or not actor_user_id.strip():
raise ValueError("actor_source 'user' requires actor_user_id")
if actor_child_id is not None:
raise ValueError(
"actor_source 'user' must not carry actor_child_id"
)
return "user", actor_user_id.strip(), None
if actor_user_id is not None:
raise ValueError("actor_source 'panel' must not carry actor_user_id")
if actor_child_id is None:
raise ValueError(
"actor_source 'panel' requires actor_child_id"
)
return (
"panel",
None,
_validate_int_id(actor_child_id, "actor_child_id"),
)
def _validate_was_on_time(
event_type: str, was_on_time: object
) -> bool | None:
"""Return a real bool or None; completed events require True/False."""
if was_on_time is not None and type(was_on_time) is not bool:
raise ValueError(
f"was_on_time must be True, False or None, got {was_on_time!r}"
)
if event_type == EVENT_COMPLETED and was_on_time is None:
raise ValueError(
"a completed event requires was_on_time (True/False)"
)
return was_on_time
async def append_event(
database: NestQuestDatabase,
instance_id: int,
child_id: int,
event_type: str,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
was_on_time: bool | None = None,
now: datetime.datetime | None = None,
) -> CompletionEventRecord:
"""Append one completion or un-completion event and return the row.
Validates actor shapes and the completed-event on-time requirement
before calling :meth:`CompletionEventsDao.append`. ``occurred_at``
is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
child_id = _validate_int_id(child_id, "child_id")
event_type = _validate_event_type(event_type)
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
was_on_time = _validate_was_on_time(event_type, was_on_time)
occurred_at = _stamp_occurred_at(now)
return await CompletionEventsDao(database).append(
instance_id,
child_id,
event_type,
actor_source,
occurred_at,
was_on_time,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
)
async def instance_state(
database: NestQuestDatabase,
instance_id: int,
*,
today: datetime.date | None = None,
) -> str:
"""Return ``open``, ``done``, or ``missed`` from the latest event.
No events, or a latest event of uncompleted, is ``open`` unless
``due_date`` is before ``today`` (HA-local, threaded), in which
case it is ``missed``. A latest event of completed is ``done``
even when past due. ``missed`` is never stored as an event_type.
Unknown ``instance_id`` raises ValueError naming the field.
Existence and the latest event are read inside the connection lock
so the answer is one snapshot.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
today_date = _resolve_today(today)
async with _connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
return _derived_state(instance, latest, today_date)
class CompletionResult(str):
"""The derived instance state, carrying the append verdict.
A ``str`` subclass so every existing ``result == "done"`` contract
keeps working; ``appended`` reports whether THIS call actually
appended an event (decided under the same lock as the append), so
a caller — the Feature 09 service path firing bus events —
announces a transition exactly once even under a concurrent
duplicate call where the loser's write is a no-op. A completion
also carries the ``was_on_time`` verdict computed under that same
lock, so the event payload never re-reads mutable latest state a
racing reversal could have replaced.
"""
appended: bool
was_on_time: bool | None
def __new__(
cls,
state: str,
*,
appended: bool,
was_on_time: bool | None = None,
) -> "CompletionResult":
obj = super().__new__(cls, state)
obj.appended = appended
obj.was_on_time = was_on_time
return obj
async def complete_instance(
database: NestQuestDatabase,
instance_id: int,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> CompletionResult:
"""Append a completed event unless the instance is already done.
Returns the derived state ``done``; ``appended`` reports whether
THIS call appended (False on an already-done no-op). Unknown
``instance_id`` raises ValueError naming the field. Existence,
latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append.
``was_on_time`` is computed from the HA-local completion moment
(threaded ``now`` / ``today``) against the instance ``due_date``
and optional ``due_time``. ``now`` is the timezone-aware HA-local
datetime; its wall clock is never converted to UTC. Same-day
``due_time`` comparison requires ``now``.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
async with _reentrant_connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
if latest is not None and latest.event_type == EVENT_COMPLETED:
return CompletionResult("done", appended=False)
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
# Computed under the completion lock and carried on the result:
# the bus event must report THIS completion's timing, never a
# re-read of the latest event a racing reversal could replace.
on_time = _was_on_time(instance, now, today)
await append_event(
database,
instance.id,
instance.child_id,
EVENT_COMPLETED,
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=on_time,
now=now,
)
return CompletionResult("done", appended=True, was_on_time=on_time)
async def uncomplete_instance(
database: NestQuestDatabase,
instance_id: int,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> CompletionResult:
"""Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``, or ``missed`` when the instance
is past due (``due_date`` before HA-local ``today``); ``appended``
reports whether THIS call appended (False on an already-open
no-op). Unknown ``instance_id`` raises ValueError naming the
field. Existence, latest-event, and the append run under one
connection lock so a concurrent uncomplete cannot double-append.
The original completed row is not modified.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
today_date = _resolve_today(today)
async with _reentrant_connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
return CompletionResult(
_derived_state(instance, latest, today_date),
appended=False,
)
if latest.event_type != EVENT_COMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
latest = await append_event(
database,
instance.id,
instance.child_id,
EVENT_UNCOMPLETED,
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=None,
now=now,
)
return CompletionResult(
_derived_state(instance, latest, today_date), appended=True
)
async def list_missed_for_child(
database: NestQuestDatabase,
child_id: int,
range_start: str,
range_end: str,
*,
today: datetime.date | None = None,
) -> list[QuestInstanceRecord]:
"""Return the child's missed instances due in the closed date range.
Missed means the latest event state is open and ``due_date`` is
before ``today`` (HA-local, threaded). Uses
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
Never appends a missed event. Instances and latest events are
read inside the connection lock so the list is one snapshot.
"""
child_id = _validate_int_id(child_id, "child_id")
today_date = _resolve_today(today)
async with _connection_lock(database):
instances = await QuestInstancesDao(database).list_by_date_range(
child_id, range_start, range_end
)
events = CompletionEventsDao(database)
missed: list[QuestInstanceRecord] = []
for instance in instances:
latest = await events.get_latest_for_instance(instance.id)
if _derived_state(instance, latest, today_date) == "missed":
missed.append(instance)
return missed
+127
View File
@@ -0,0 +1,127 @@
"""Constants for the NestQuest integration."""
from __future__ import annotations
import logging
import re
DOMAIN = "nestquest"
LOGGER_NAME = "custom_components.nestquest"
LOGGER = logging.getLogger(LOGGER_NAME)
#: Strict 24-hour ``HH:MM`` pattern shared by the options flow and the
#: settings object's time validation, so a stored option and a freshly
#: validated form submit parse the same way. Defined here (HA-free) so
#: both the integration's options flow and the core settings module
#: import ONE pattern instead of re-copying it.
TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
# Platforms set up per config entry (Feature 10): the per-child day
# sensors and binary sensors.
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
# panel and admin cards listen for these; Feature 10 fires completed,
# uncompleted, and child_day_complete from the service paths, and
# Feature 11's nightly sweep fires quest_missed.
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
EVENT_QUEST_MISSED = "nestquest_quest_missed"
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
#: plus the existing Feature 07 regenerate service).
SERVICE_COMPLETE_QUEST = "complete_quest"
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
SERVICE_MANAGE_CHILD = "manage_child"
SERVICE_REGENERATE = "regenerate"
DOMAIN_SERVICES: tuple[str, ...] = (
SERVICE_COMPLETE_QUEST,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_UPDATE_QUEST_DEFINITION,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
)
SQLITE_DB_FILENAME = "nestquest.db"
# Configuration keys and default values
CONF_HORIZON_DAYS = "horizon_days"
DEFAULT_HORIZON_DAYS = 14
CONF_DAY_ROLLOVER_TIME = "day_rollover_time"
DEFAULT_DAY_ROLLOVER_TIME = "00:00"
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
DEFAULT_PANEL_IDLE_TIMEOUT = 300
# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only
# services. Stored as IDs, never usernames (feature guardrail). The
# database allowlist is authoritative; the config entry carries the same
# list as a disaster-recovery copy so a lost database can be re-seeded.
CONF_ADMIN_USER_IDS = "admin_user_ids"
# Shared coordinator refresh interval (Feature 10), in SECONDS. Every
# entity reads one snapshot per refresh; completions push an immediate
# refresh so this poll is only the fallback cadence.
CONF_UPDATE_INTERVAL = "update_interval"
DEFAULT_UPDATE_INTERVAL = 300
MIN_UPDATE_INTERVAL = 30
# Notification automations (Feature 11). The notify target is a free-text
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
# household fills in — never a hard-coded personal target; the times are
# strict HH:MM local; each of the four automations has an independent
# enable toggle. Per the owner-settled resolution the end-of-day report
# defaults to 20:00 — deliberately BEFORE the midnight missed sweep —
# and never defaults to the rollover time.
CONF_NOTIFY_TARGET = "notify_target"
CONF_MORNING_SUMMARY_TIME = "morning_summary_time"
DEFAULT_MORNING_SUMMARY_TIME = "08:00"
CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time"
DEFAULT_AFTERNOON_REMINDER_TIME = "15:00"
CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time"
DEFAULT_END_OF_DAY_REPORT_TIME = "20:00"
CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled"
CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True
# Quest windows (D-008): a definition may declare several day windows;
# each produces its own instance per day and groups the panel's Quest
# Log into three columns. The clock ranges are stored here so they stay
# configurable later. Boundaries: a window's end equals the next
# window's start; classification (Feature 07 materialisation) treats a
# due time exactly on the boundary as belonging to the LATER window, so
# 12:00 is afternoon (not morning) and 17:00 is evening (not
# afternoon). Times after 21:00 belong to no window.
WINDOW_MORNING = "morning"
WINDOW_AFTERNOON = "afternoon"
WINDOW_EVENING = "evening"
QUEST_WINDOWS: tuple[str, ...] = (
WINDOW_MORNING,
WINDOW_AFTERNOON,
WINDOW_EVENING,
)
#: Inclusive "HH:MM" bounds per window, in 24-hour local time.
WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = {
WINDOW_MORNING: ("00:00", "11:59"),
WINDOW_AFTERNOON: ("12:00", "17:00"),
WINDOW_EVENING: ("17:00", "21:00"),
}
@@ -0,0 +1,319 @@
"""Typed DAO layer for the children and admin_users tables.
All SQL for ``children`` and ``admin_users`` lives in this module per
the feature guardrail: callers get typed dataclasses back and never see
raw rows or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Error contract: violations surface as the underlying
:class:`sqlite3.IntegrityError` (duplicate, foreign-key, or CHECK
failure) from the execute call; the DAO adds no wrapping exception
type. A get that matches nothing returns ``None``; a mutation that
matches nothing is reported by :class:`~.db.ExecutionResult.rowcount`,
which each method returns where the caller may need it (reorder, and
the admin remove/set paths).
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from .db import NestQuestDatabase
@dataclass(frozen=True)
class ChildRecord:
"""One row of ``children``."""
id: int
display_name: str
colour: str | None
avatar_ref: str | None
sort_order: int
is_active: bool
created_at: str
@dataclass(frozen=True)
class AdminUserRecord:
"""One row of ``admin_users``."""
ha_user_id: str
added_at: str
def _child_from_row(row: tuple) -> ChildRecord:
"""Build a :class:`ChildRecord` from a SELECT row."""
return ChildRecord(
id=row[0],
display_name=row[1],
colour=row[2],
avatar_ref=row[3],
sort_order=row[4],
is_active=bool(row[5]),
created_at=row[6],
)
_CHILD_COLUMNS = (
"id, display_name, colour, avatar_ref, sort_order, is_active, created_at"
)
#: One asyncio.Lock per (connection wrapper, running loop), shared by
#: every ChildrenDao instance over the same database: the wrapper
#: rejects a second concurrent transaction with RuntimeError, so ALL
#: transactional DAO operations on one connection must queue here, not
#: per-instance. Keyed like the migration runner's lock; entries are
#: never evicted (a config entry holds one wrapper per process).
_CONNECTION_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _connection_lock(database) -> asyncio.Lock:
"""Return the shared transaction-serializing lock for ``database``."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _CONNECTION_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_CONNECTION_LOCKS[key] = lock
return lock
class ChildrenDao:
"""Typed async data access for the ``children`` table.
Deletion is deliberately absent: children are deactivated, never
deleted, because completion history references them (feature
guardrail).
"""
def __init__(self, database) -> None:
self._database = database
async def create(
self,
display_name: str,
created_at: str,
*,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int = 0,
is_active: bool = True,
) -> ChildRecord:
"""Insert one child and return the record as stored."""
result = await self._database.execute(
"INSERT INTO children (display_name, colour, avatar_ref, "
"sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(
display_name,
colour,
avatar_ref,
sort_order,
int(is_active),
created_at,
),
)
child = await self.get(result.lastrowid)
assert child is not None
return child
async def get(self, child_id: int) -> ChildRecord | None:
"""Return the child with ``child_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_CHILD_COLUMNS} FROM children WHERE id = ?",
(child_id,),
)
return _child_from_row(row) if row is not None else None
async def list_active(self) -> list[ChildRecord]:
"""Return active children ordered by sort_order, then id."""
rows = await self._database.fetch_all(
f"SELECT {_CHILD_COLUMNS} FROM children WHERE is_active = 1 "
"ORDER BY sort_order, id"
)
return [_child_from_row(row) for row in rows]
async def list_all(self) -> list[ChildRecord]:
"""Return all children ordered by sort_order, then id."""
rows = await self._database.fetch_all(
f"SELECT {_CHILD_COLUMNS} FROM children "
"ORDER BY sort_order, id"
)
return [record for record in map(_child_from_row, rows)]
async def update(
self,
child_id: int,
*,
display_name: str | None = None,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int | None = None,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Only non-None arguments are written, so a caller cannot
accidentally blank a column by omitting it.
"""
assignments: list[str] = []
parameters: list[object] = []
if display_name is not None:
assignments.append("display_name = ?")
parameters.append(display_name)
if colour is not None:
assignments.append("colour = ?")
parameters.append(colour)
if avatar_ref is not None:
assignments.append("avatar_ref = ?")
parameters.append(avatar_ref)
if sort_order is not None:
assignments.append("sort_order = ?")
parameters.append(sort_order)
if not assignments:
return 0
parameters.append(child_id)
result = await self._database.execute(
f"UPDATE children SET {', '.join(assignments)} WHERE id = ?",
tuple(parameters),
)
return result.rowcount
async def set_active(self, child_id: int, is_active: bool) -> int:
"""Set the child's active flag; returns rows updated (0 if absent)."""
result = await self._database.execute(
"UPDATE children SET is_active = ? WHERE id = ?",
(int(is_active), child_id),
)
return result.rowcount
async def reorder(self, ordered_ids: list[int]) -> None:
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
The list must be a COMPLETE permutation of the children table:
every existing child id appears exactly once. Partial lists
(missing an existing child) and unknown ids (naming a child
that does not exist) are rejected with ValueError BEFORE any
write, leaving the current order untouched — a silent partial
reorder would leave unlisted children with stale positions and
hide a caller bug. Duplicate ids are rejected up front: they
cannot all hold distinct positions, and silently assigning the
last occurrence's index would hide a caller bug.
Runs inside one transaction; positions are staged at offset
-len (all negative, so any permutation of the same ids never
collides mid-reorder under the wrapper's single-connection
serialization), then finalized 0..n-1.
Concurrent reorder() calls on one connection are serialized by
a connection-scoped asyncio lock (shared across every ChildrenDao
over the same wrapper, however many instances): the wrapper's
transaction() rejects a second concurrent transaction with
RuntimeError, so racing callers would crash instead of
queueing. The lock makes the second call wait, then run
against the first call's final state — which also keeps the
completeness check honest: the re-read of the table happens
inside the same lock and transaction as the writes.
"""
if len(set(ordered_ids)) != len(ordered_ids):
raise ValueError(
"reorder() received duplicate child ids; every id must "
"appear at most once"
)
async with _connection_lock(self._database):
async with self._database.transaction():
rows = await self._database.fetch_all(
f"SELECT id FROM children"
)
existing = {row[0] for row in rows}
listed = set(ordered_ids)
missing = sorted(existing - listed)
unknown = sorted(listed - existing)
if missing or unknown:
details = []
if missing:
details.append(
f"missing child ids {missing} (a partial "
"reorder would leave them with stale "
"positions)"
)
if unknown:
details.append(
f"unknown child ids {unknown}"
)
raise ValueError(
"reorder() must list every child exactly once; "
+ "; ".join(details)
)
offset = len(ordered_ids)
await self._database.execute_many(
"UPDATE children SET sort_order = ? WHERE id = ?",
[
(-(offset + index), child_id)
for index, child_id in enumerate(ordered_ids)
],
)
await self._database.execute_many(
"UPDATE children SET sort_order = ? WHERE id = ?",
[
(index, child_id)
for index, child_id in enumerate(ordered_ids)
],
)
class AdminUsersDao:
"""Typed async access to the ``admin_users`` allowlist.
The allowlist stores HA user IDs, never usernames (feature
guardrail); an empty allowlist means no admin, enforced by callers
resolving through :meth:`exists`.
"""
def __init__(self, database) -> None:
self._database = database
async def add(self, ha_user_id: str, added_at: str) -> bool:
"""Add a user to the allowlist; True if inserted, False if present.
Idempotent under concurrency: a single INSERT ... ON CONFLICT DO
NOTHING decides and inserts in one statement (the wrapper
serializes statements, not check-then-insert sequences, so a
SELECT/INSERT pair would let two concurrent adds both see no
row and the second one raise IntegrityError). rowcount reports
whether the insert actually happened.
"""
result = await self._database.execute(
"INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?) "
"ON CONFLICT (ha_user_id) DO NOTHING",
(ha_user_id, added_at),
)
return result.rowcount > 0
async def remove(self, ha_user_id: str) -> bool:
"""Remove a user from the allowlist; True if a row was removed."""
result = await self._database.execute(
"DELETE FROM admin_users WHERE ha_user_id = ?",
(ha_user_id,),
)
return result.rowcount > 0
async def list(self) -> list[AdminUserRecord]:
"""Return every allowlisted user, oldest first."""
rows = await self._database.fetch_all(
"SELECT ha_user_id, added_at FROM admin_users "
"ORDER BY added_at, ha_user_id"
)
return [
AdminUserRecord(ha_user_id=row[0], added_at=row[1])
for row in rows
]
async def exists(self, ha_user_id: str) -> bool:
"""Return True if ``ha_user_id`` is allowlisted."""
row = await self._database.fetch_one(
"SELECT 1 FROM admin_users WHERE ha_user_id = ?",
(ha_user_id,),
)
return row is not None
@@ -0,0 +1,832 @@
"""Typed DAO layer for quest_instances and the append-only completion_events.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Append-only policy (Feature 08, D-005): ``completion_events`` is
strictly append-only. This module is where that property is enforced
mechanically — the completion-events DAO below exposes NO update or
delete method at all; completions append, un-completions append a
separate reversal event, and instance state derives from the latest
event. A package-wide guard test (bottom of the test module) keeps
mutation SQL for this table out of every other module.
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
(definition_id, child_id, due_date, window) — re-running
materialization for a tuple whose instance already exists refreshes
only the generation-time snapshot columns and never duplicates a row.
The schema's UNIQUE(definition_id, child_id, due_date, window) is the
last line of defense. The upsert refuses a child that is not an
assignee of the definition and a window outside ``const.QUEST_WINDOWS``
(D-008 multi-assignee, multi-window model).
``delete_future_uncompleted`` implements the Feature 06/07 rule that
reassignment and schedule edits regenerate future instances: it
removes only instances at or after the cutoff that have NO completion
event, inside one transaction, so an instance completed mid-call can
never be deleted. Instances in the past are never touched (Feature 07
guardrail: never delete history), and no method here deletes a single
instance that has a completion event.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import _connection_lock
from .dao_rules import _validate_date, _validate_window
from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
#: One accepted shape bounds lexicographic range queries correctly, so
#: the occurred-at scope does not guess at timestamp spellings.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _today() -> datetime.date:
"""Return the current local calendar date (the single date source).
Every "today" read in this module goes through this one function so
the delete clamp and the materialize walk's no-past guard read the
same clock, and a caller or test can pin/roll that clock
deterministically (e.g. a queued-across-midnight regression). Pure
clock read — no business rule lives here.
"""
return datetime.date.today()
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the caller-resolved "today", or the host date when absent.
The no-past guard and the delete cutoffs must compare due dates
against the SAME calendar date the materialization horizon was built
from. Home Assistant's configured time zone can lag the host clock
around midnight, so when the caller has already resolved the local
date (``today``) that anchor is authoritative; ``None`` falls back to
the host clock (:func:`_today`), which is exactly the previous
behavior for callers with no HA-local date.
"""
return _today() if today is None else today
def _reject_past(due_date: str, today_iso: str) -> None:
"""Raise unless ``due_date`` is on or after ``today_iso``.
The single no-past rule shared by :meth:`QuestInstancesDao.upsert`
and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never
generated in the past, measured against the resolved "today".
"""
if due_date < today_iso:
raise ValueError(
f"instances are never generated in the past: due_date "
f"{due_date!r} is before today {today_iso!r}"
)
def _validate_utc_timestamp(value: str, field: str) -> None:
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
Same strictness policy as _validate_date: parse with the exact
format, then round-trip the parsed value against the input so
non-padded or re-ordered shapes are rejected. The round-trip
strips the UTC offset (isoformat() omits it when the offset is
zero), so the suffix is appended before comparing.
"""
try:
parsed = datetime.datetime.strptime(
value, _UTC_TIMESTAMP_FORMAT
)
except (TypeError, ValueError):
raise ValueError(
f"{field} must be a UTC ISO-8601 timestamp "
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
) from None
if parsed.isoformat() + "+00:00" != value:
raise ValueError(
f"{field} must be a strict UTC timestamp "
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
)
def _next_day_start(date_str: str) -> str:
"""Return the UTC timestamp one second past ``date_str``'s last second.
Gives range queries an exact half-open upper bound without
fractional-second guessing.
"""
day = datetime.date.fromisoformat(date_str)
next_day = day + datetime.timedelta(days=1)
return f"{next_day.isoformat()}T00:00:00+00:00"
@dataclass(frozen=True)
class QuestInstanceRecord:
"""One row of ``quest_instances``."""
id: int
definition_id: int
child_id: int
window: str
due_date: str
due_time: str | None
generated_at: str
@dataclass(frozen=True)
class CompletionEventRecord:
"""One row of ``completion_events`` (append-only table)."""
id: int
instance_id: int
child_id: int
event_type: str
actor_source: str
actor_user_id: str | None
actor_child_id: int | None
occurred_at: str
was_on_time: bool | None
_INSTANCE_COLUMNS = (
"id, definition_id, child_id, window, due_date, due_time, generated_at"
)
_EVENT_COLUMNS = (
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
"actor_child_id, occurred_at, was_on_time"
)
#: The only event types the append path accepts; the schema CHECK is
#: the backstop, this is the DAO's typed front door.
EVENT_COMPLETED = "completed"
EVENT_UNCOMPLETED = "uncompleted"
def _instance_from_row(row: tuple) -> QuestInstanceRecord:
return QuestInstanceRecord(
id=row[0],
definition_id=row[1],
child_id=row[2],
window=row[3],
due_date=row[4],
due_time=row[5],
generated_at=row[6],
)
def _event_from_row(row: tuple) -> CompletionEventRecord:
return CompletionEventRecord(
id=row[0],
instance_id=row[1],
child_id=row[2],
event_type=row[3],
actor_source=row[4],
actor_user_id=row[5],
actor_child_id=row[6],
occurred_at=row[7],
was_on_time=None if row[8] is None else bool(row[8]),
)
class QuestInstancesDao:
"""Typed async access to the ``quest_instances`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert(
self,
definition_id: int,
child_id: int,
due_date: str,
generated_at: str,
*,
window: str,
due_time: str | None = None,
today: datetime.date | None = None,
) -> QuestInstanceRecord:
"""Create the dated instance for the widened key idempotently.
The instance key is (definition_id, child_id, due_date, window)
(D-008): running materialization twice for the same tuple
yields ONE row — the ON CONFLICT path refreshes the
generation-time snapshot columns (due_time) and regenerates the
stamp, but can never create a duplicate. The schema's
UNIQUE(definition_id, child_id, due_date, window) backs this
up, so the twice-daily case (two windows, one child, one date)
yields two rows and the shared case (three assignees, one
window) yields three.
Guardrails enforced here:
- ``due_date`` must be TODAY or later: instances are never
generated in the past (Feature 07). The caller's
materialization horizon is future-dated by definition.
``today`` optionally pins the resolved HA-local date the
guard compares against (falling back to the host clock when
omitted) so a household time zone behind the host around
midnight is not misread as "past".
- ``window`` must be one of ``const.QUEST_WINDOWS``.
- An existing instance WITH a completion event is IMMUTABLE:
the conflict path must not rewrite its snapshot columns
(Feature 07/08). The upsert refuses with ValueError in that
case — silently no-oping would mask a materialization bug.
- The child must be an assignee of the definition (D-008
multi-assignee model).
The definition must exist (validated atomically under the
connection lock so a concurrent definition deactivation or
child deletion cannot slip between check and insert).
"""
_validate_date(due_date, "due_date")
_validate_window(window)
# Fail fast on obviously-past dates BEFORE queuing on the
# connection lock (a nice early error); the AUTHORITATIVE
# check runs after the lock is acquired, because a caller can
# wait on the lock across midnight — a date that was "today"
# at call time may be "yesterday" by the time the INSERT runs.
# A caller-supplied ``today`` (HA-local) is used for BOTH
# checks and never re-read, since it is already the anchor the
# batch runs against.
today_date = _resolve_today(today)
_reject_past(due_date, today_date.isoformat())
async with _connection_lock(self._database):
async with self._database.transaction():
# Re-read "today" under the lock: this is the date the
# insert actually executes on, so the no-past rule
# holds across midnight rollovers. A pinned HA-local
# anchor is exempt from the re-read (see above).
if today is None:
today_date = _today()
_reject_past(due_date, today_date.isoformat())
definition = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions WHERE id = ?",
(definition_id,),
)
if definition is None:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
assignee = await self._database.fetch_one(
"SELECT 1 FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
if assignee is None:
raise ValueError(
f"quest definition {definition_id} is not "
f"assigned to child {child_id}; instance must "
"carry an assigned child"
)
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? "
"AND due_date = ? AND window = ?)",
(definition_id, child_id, due_date, window),
)
if existing is not None:
raise ValueError(
f"instance for (definition {definition_id}, "
f"child {child_id}, due_date {due_date!r}, "
f"window {window!r}) already has a completion "
"event and is immutable; it cannot be regenerated"
)
await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, child_id, due_date, "
"window) DO UPDATE SET "
"due_time = excluded.due_time, "
"generated_at = excluded.generated_at",
(
definition_id,
child_id,
window,
due_date,
due_time,
generated_at,
),
)
instance = await self.get(
definition_id, child_id, due_date, window
)
assert instance is not None
return instance
async def upsert_if_valid(
self,
definition_id: int,
child_id: int,
due_date: str,
generated_at: str,
*,
window: str,
due_time: str | None = None,
today: datetime.date | None = None,
) -> QuestInstanceRecord | None:
"""Insert one dated instance iff its tuple is still materializable.
This is the materialization walk's single write path. Unlike the
generic :meth:`upsert`, which validates only existence and
assignment, this method re-validates EVERY materialization
precondition in the SAME connection-lock-held transaction as the
INSERT — closing the check-then-insert gap a concurrent config
write could otherwise slip through:
- the definition exists AND is active (a definition deactivated
after the input snapshot is skipped, never written);
- the child exists AND is active (a deactivated child is skipped);
- the (definition, child) assignment link still exists (a removed
assignment is skipped);
- the window is still declared on the definition (a removed window
is skipped);
- the window's live ``due_time`` still EQUALS the caller-supplied
``due_time`` (NULL-safe: NULL == NULL counts as equal; a window
with no due time matches a snapshot with no due time). A
window-time edit landed after the input snapshot makes the
caller's ``due_time`` stale, and writing it would overwrite the
newer due_time a concurrent regeneration already upserted — so
the tuple is skipped instead of written.
The due_time check closes the one check-then-insert gap this
method previously left open: it re-validated every OTHER
precondition but then wrote the snapshot's ``due_time``
unconditionally, so a stale materialization could clobber a
newer, concurrently-regenerated due_time.
Returns the stored instance, or ``None`` when a precondition
failed — the tuple no longer materializable, which the walk SKIPS
rather than raising, so the rest of the batch is unaffected. The
no-past rule (instances are never generated in the past) is
preserved from :meth:`upsert` and still raises ValueError; ``today``
optionally pins the HA-local anchor it compares against. An
instance that ALREADY has a completion event is also skipped
(returns ``None``) rather than raised: it is immutable, so the
walk leaves it untouched instead of failing the batch — the
generic :meth:`upsert` keeps its hard refusal for the
delete/regenerate path.
"""
_validate_date(due_date, "due_date")
_validate_window(window)
# Fail fast on obviously-past dates before queuing on the lock;
# the authoritative check re-runs under the lock (see upsert).
today_date = _resolve_today(today)
_reject_past(due_date, today_date.isoformat())
async with _connection_lock(self._database):
async with self._database.transaction():
if today is None:
today_date = _today()
_reject_past(due_date, today_date.isoformat())
still_valid = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions d "
"JOIN quest_definition_assignees a "
"ON a.definition_id = d.id "
"JOIN children c ON c.id = a.child_id "
"JOIN quest_definition_windows w "
"ON w.definition_id = d.id "
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
"AND d.is_active = 1 AND c.is_active = 1 "
"AND w.due_time IS ? LIMIT 1",
(definition_id, child_id, window, due_time),
)
if still_valid is None:
# A config change invalidated the tuple since the
# snapshot: skip it rather than abort the batch.
return None
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? "
"AND due_date = ? AND window = ?)",
(definition_id, child_id, due_date, window),
)
if existing is not None:
# The instance is already completed and therefore
# immutable: the walk SKIPS it (no-op) rather than
# rewriting its snapshot columns or failing the
# batch. This is the materialization walk's skip,
# distinct from the generic :meth:`upsert` refusal.
return None
await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, child_id, due_date, "
"window) DO UPDATE SET "
"due_time = excluded.due_time, "
"generated_at = excluded.generated_at",
(
definition_id,
child_id,
window,
due_date,
due_time,
generated_at,
),
)
instance = await self.get(
definition_id, child_id, due_date, window
)
assert instance is not None
return instance
async def get(
self,
definition_id: int,
child_id: int,
due_date: str,
window: str,
) -> QuestInstanceRecord | None:
"""Return the instance for the widened key tuple, or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? AND due_date = ? "
"AND window = ?",
(definition_id, child_id, due_date, window),
)
return _instance_from_row(row) if row is not None else None
async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None:
"""Return the instance with ``instance_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?",
(instance_id,),
)
return _instance_from_row(row) if row is not None else None
async def list_by_child_and_date(
self, child_id: int, due_date: str
) -> list[QuestInstanceRecord]:
"""Return the child's instances due exactly on ``due_date``."""
_validate_date(due_date, "due_date")
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date = ? ORDER BY id",
(child_id, due_date),
)
return [_instance_from_row(row) for row in rows]
async def list_by_date_range(
self,
child_id: int,
range_start: str,
range_end: str,
) -> list[QuestInstanceRecord]:
"""Return the child's instances with due_date in the closed range."""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date >= ? AND due_date <= ? "
"ORDER BY due_date, id",
(child_id, range_start, range_end),
)
return [_instance_from_row(row) for row in rows]
async def list_missed_unswept(
self, today_iso: str, watermark: str | None
) -> list[QuestInstanceRecord]:
"""Return past-due open instances the sweep has not announced.
Feature 11's missed-sweep query: ``due_date`` strictly before
``today_iso`` (past due), at or after ``watermark`` when one is
recorded, and with NO completion event ever (the Feature 08
append-only contract: an instance that was ever completed is
never reported missed, even when later reversed — history keeps
both rows and the sweep never mutates any). The watermark is
the HA-local date the sweep last ran, and the window is
``[watermark, today)``: the nightly run entering day D+1 sweeps
exactly day D's instances, so every day's quests are announced
once — at the midnight after their due date — and a same-night
rerun (watermark == today) sees an empty window. Read-only;
ordered deterministically by (due_date, id).
"""
_validate_date(today_iso, "today_iso")
if watermark is not None:
_validate_date(watermark, "watermark")
sql = (
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE due_date < ? "
"AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")"
)
parameters: tuple = (today_iso,)
if watermark is not None:
sql += " AND due_date >= ?"
parameters += (watermark,)
sql += " ORDER BY due_date, id"
rows = await self._database.fetch_all(sql, parameters)
return [_instance_from_row(row) for row in rows]
async def delete_future_uncompleted(
self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None
) -> int:
"""Delete the definition's open instances at/after cutoff.
Implements regeneration after a definition edit or reassignment
(Features 06/07): 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: "future"
is measured against TODAY (materialization regenerates the
rolling horizon from now), so a caller cannot use this to
rewrite past open instances either — the day-rollover missed
sweep (Feature 11) handles past-due instances instead. ``today``
optionally pins the HA-local anchor the clamp compares against
(falling back to the host clock when omitted). 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: the DELETE actually
# executes on this date, so a caller queued across
# midnight cannot delete instances that became past
# while it waited (backdating is clamped to the
# execution date, not the call date).
today_date = _resolve_today(today)
effective_cutoff = max(cutoff_date, today_date.isoformat())
result = await self._database.execute(
"DELETE FROM quest_instances WHERE definition_id = ? "
"AND due_date >= ? AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")",
(definition_id, effective_cutoff),
)
return result.rowcount
async def delete_future_uncompleted_for_child(
self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None
) -> tuple[int, str]:
"""Delete the child's open instances at/after cutoff, across every
definition, returning ``(deleted, effective_cutoff)``.
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). ``today`` optionally pins the HA-local anchor the clamp
compares against (falling back to the host clock when omitted).
Any instance with a completion event is never touched, regardless
of its date.
Returns the number deleted and the effective cutoff that was
actually applied (``max(cutoff_date, today)``, read under the
lock). The caller (:func:`~.materialize.regenerate_for_child`)
passes that effective cutoff back as its re-materialization anchor,
so the delete and the rebuild share ONE execution-day even when the
call is queued across midnight — instead of the caller re-reading a
stale "today" and materializing a window the delete already
superseded.
"""
_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_date = _resolve_today(today)
effective_cutoff = max(cutoff_date, today_date.isoformat())
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, effective_cutoff
class CompletionEventsDao:
"""Typed append-only access to the ``completion_events`` table.
APPEND-ONLY BY CONSTRUCTION: this class exposes ``append`` and the
three read methods below, and NOTHING else. There is no update,
no delete, no upsert — completions and un-completions both arrive
through :meth:`append` as new rows, and history is immutable
(Feature 08 done-condition: no code path updates or deletes an
event). The package-wide leak-guard test additionally fails if
mutation SQL for this table ever appears in any module.
"""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def append(
self,
instance_id: int,
child_id: int,
event_type: str,
actor_source: str,
occurred_at: str,
was_on_time: bool | None,
*,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
) -> CompletionEventRecord:
"""Append one completion or un-completion event.
``actor_source`` is 'user' (an admin action: ``actor_user_id``
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
id, and ``actor_child_id`` carrying the tapped child profile,
D-008); the actor-pair CHECKs enforce this shape at the schema
level. ``occurred_at`` must be the module's one strict UTC
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
queries stay exact. ``was_on_time`` is required (True/False)
for completions; for un-completions it may be None or the
reversed completion's flag (Feature 08 still deciding). The
instance, child and — for panel events — the tapped child must
exist — validated atomically under the connection lock.
"""
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {event_type!r}"
)
if actor_source not in ("user", "panel"):
raise ValueError(
f"actor_source must be 'user' or 'panel', got "
f"{actor_source!r}"
)
if actor_source == "user" and not actor_user_id:
raise ValueError(
"actor_source 'user' requires actor_user_id"
)
if actor_source == "panel" and actor_user_id is not None:
raise ValueError(
"actor_source 'panel' must not carry actor_user_id"
)
if actor_source == "panel" and actor_child_id is None:
raise ValueError(
"actor_source 'panel' requires actor_child_id "
"(the tapped child profile)"
)
if actor_source == "user" and actor_child_id is not None:
raise ValueError(
"actor_source 'user' must not carry actor_child_id"
)
if was_on_time is not None and type(was_on_time) is not bool:
raise ValueError(
f"was_on_time must be True, False or None, got "
f"{was_on_time!r}"
)
if event_type == EVENT_COMPLETED and was_on_time is None:
raise ValueError(
"a completed event requires was_on_time (True/False)"
)
_validate_utc_timestamp(occurred_at, "occurred_at")
async with _connection_lock(self._database):
async with self._database.transaction():
instance = await self._database.fetch_one(
"SELECT child_id FROM quest_instances WHERE id = ?",
(instance_id,),
)
if instance is None:
raise ValueError(
f"quest instance {instance_id} does not exist"
)
if instance[0] != child_id:
raise ValueError(
f"quest instance {instance_id} belongs to child "
f"{instance[0]}, not {child_id}"
)
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
if actor_child_id is not None:
tapped = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?",
(actor_child_id,),
)
if tapped is None:
raise ValueError(
f"actor_child_id {actor_child_id} does not "
"reference an existing child"
)
result = await self._database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, actor_user_id, "
"actor_child_id, occurred_at, was_on_time) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
instance_id,
child_id,
event_type,
actor_source,
actor_user_id,
actor_child_id,
occurred_at,
None if was_on_time is None else int(was_on_time),
),
)
event = await self._database.fetch_one(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE id = ?",
(result.lastrowid,),
)
assert event is not None
return _event_from_row(event)
async def list_by_instance(
self, instance_id: int
) -> list[CompletionEventRecord]:
"""Return every event for the instance, in append order."""
rows = await self._database.fetch_all(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE instance_id = ? ORDER BY id",
(instance_id,),
)
return [_event_from_row(row) for row in rows]
async def list_by_child_and_date_range(
self,
child_id: int,
range_start: str,
range_end: str,
*,
due_date_scope: bool = True,
) -> list[CompletionEventRecord]:
"""Return the child's events for instances due in the closed range.
Scopes by the INSTANCE's due date (the day the task was owed),
not the event's wall-clock timestamp: history reporting asks
"what happened to the tasks due that day" (Feature 13). Set
``due_date_scope=False`` to filter by ``occurred_at`` date
instead. Ordered by instance, then event id.
"""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
if due_date_scope:
predicate = (
"SELECT id FROM quest_instances WHERE child_id = ? "
"AND due_date >= ? AND due_date <= ?"
)
parameters: list[object] = [child_id, range_start, range_end]
else:
# Occurred-at scope: occurred_at is validated to the one
# accepted UTC shape at append time, so half-open day
# bounds in that same shape are exact (no spelling drift).
occurred_start = f"{range_start}T00:00:00+00:00"
occurred_end = f"{range_end}T23:59:59+00:00"
query = (
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE child_id = ? AND occurred_at >= ? "
"AND occurred_at < ? ORDER BY instance_id, id"
)
rows = await self._database.fetch_all(
query,
[
child_id,
occurred_start,
_next_day_start(range_end),
],
)
return [_event_from_row(row) for row in rows]
rows = await self._database.fetch_all(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
f"WHERE instance_id IN ({predicate}) "
"ORDER BY instance_id, id",
tuple(parameters),
)
return [_event_from_row(row) for row in rows]
async def get_latest_for_instance(
self, instance_id: int
) -> CompletionEventRecord | None:
"""Return the instance's most recent event, or None if untouched.
Instance current state derives from this event (Feature 08):
'completed' means done, 'uncompleted' as the latest means open
again, None means never completed.
"""
row = await self._database.fetch_one(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE instance_id = ? ORDER BY id DESC LIMIT 1",
(instance_id,),
)
return _event_from_row(row) if row is not None else None
@@ -0,0 +1,44 @@
"""Typed DAO for the integration-owned ``nestquest_meta_state`` table.
Generic persistent key/value bookkeeping that belongs to the
integration itself rather than any domain table — currently the
Feature 11 missed-sweep watermark. Values are plain strings; callers
interpret them. All SQL for this table lives here per the feature
guardrails, and every method runs through
:class:`~.db.NestQuestDatabase` so nothing blocks the event loop.
"""
from __future__ import annotations
from .dao_children import _connection_lock
from .db import NestQuestDatabase
_TABLE = "nestquest_meta_state"
class MetaStateDao:
"""Typed async access to the ``nestquest_meta_state`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def get(self, key: str) -> str | None:
"""Return the stored value for ``key``, or None when absent."""
row = await self._database.fetch_one(
f"SELECT value FROM {_TABLE} WHERE key = ?", (key,)
)
return row[0] if row is not None else None
async def set(self, key: str, value: str) -> None:
"""Upsert one key/value row atomically.
The write runs inside one transaction under the shared
connection lock, so a racing reader either sees the old value
or the fully-written new one, never a half-applied state.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
await self._database.execute(
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
(key, value),
)
@@ -0,0 +1,343 @@
"""Typed DAO layer for the presence_schedules and presence_overrides tables.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Semantics per the done-condition and Feature 05:
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting
by child updates the existing row rather than raising.
- A child with NO schedule row is present every day — the absence of a
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child`
returning None, never by an empty pattern row.
- Overrides beat the pattern for their date range (that resolution is
the presence engine's job, Feature 05); the DAO only stores and
lists them. A single-day override stores the same date as both
start and end (schema CHECK requires end >= start).
Date arguments are validated as strict YYYY-MM-DD per the same policy
the rules DAO applies: the schema's lexical CHECKs cannot prove
calendar validity.
"""
from __future__ import annotations
from dataclasses import dataclass
from .dao_children import _connection_lock
from .dao_rules import _UNSET, _validate_date
from .db import NestQuestDatabase
@dataclass(frozen=True)
class PresenceScheduleRecord:
"""One row of ``presence_schedules``."""
id: int
child_id: int
cycle_length_weeks: int
anchor_date: str
pattern: str
@dataclass(frozen=True)
class PresenceOverrideRecord:
"""One row of ``presence_overrides``."""
id: int
child_id: int
start_date: str
end_date: str
is_present: bool
note: str | None
_SCHEDULE_COLUMNS = (
"id, child_id, cycle_length_weeks, anchor_date, pattern"
)
_OVERRIDE_COLUMNS = (
"id, child_id, start_date, end_date, is_present, note"
)
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord:
return PresenceScheduleRecord(
id=row[0],
child_id=row[1],
cycle_length_weeks=row[2],
anchor_date=row[3],
pattern=row[4],
)
def _override_from_row(row: tuple) -> PresenceOverrideRecord:
return PresenceOverrideRecord(
id=row[0],
child_id=row[1],
start_date=row[2],
end_date=row[3],
is_present=bool(row[4]),
note=row[5],
)
class PresenceSchedulesDao:
"""Typed async access to the ``presence_schedules`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert_by_child(
self,
child_id: int,
cycle_length_weeks: int,
anchor_date: str,
pattern: str,
) -> PresenceScheduleRecord:
"""Set the child's presence schedule, replacing any existing one.
Upsert semantics are the done-condition: a second schedule for
the same child updates the existing row rather than raising.
Runs under the connection lock so a concurrent upsert/delete
for the same child serializes instead of colliding on the
schema's UNIQUE(child_id). Pattern validity (segment shape,
segment count = cycle_length_weeks) is enforced by the schema
CHECKs; the child must exist (FK) — validated atomically here
so a deleted child cannot pass a stale check.
"""
_validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database):
async with self._database.transaction():
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
await self._database.execute(
"INSERT INTO presence_schedules (child_id, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT (child_id) DO UPDATE SET "
"cycle_length_weeks = excluded.cycle_length_weeks, "
"anchor_date = excluded.anchor_date, "
"pattern = excluded.pattern",
(child_id, cycle_length_weeks, anchor_date, pattern),
)
schedule = await self.get_by_child(child_id)
assert schedule is not None
return schedule
async def get_by_child(
self, child_id: int
) -> PresenceScheduleRecord | None:
"""Return the child's schedule, or None when the child has none.
None is meaningful and UNAMBIGUOUS here: the child exists and
has no schedule row, i.e. the child is present every day
(Feature 05 guardrail). An unknown child id raises ValueError
instead of returning None, because "nonexistent child" must
never be silently read as "present every day".
"""
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
row = await self._database.fetch_one(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
"WHERE child_id = ?",
(child_id,),
)
return _schedule_from_row(row) if row is not None else None
async def delete(self, child_id: int) -> bool:
"""Remove the child's schedule; True if a row was removed.
After deletion the child is present every day (no row means
no restriction). Deleting a schedule the child never had
returns False.
"""
result = await self._database.execute(
"DELETE FROM presence_schedules WHERE child_id = ?",
(child_id,),
)
return result.rowcount > 0
class PresenceOverridesDao:
"""Typed async access to the ``presence_overrides`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def create(
self,
child_id: int,
start_date: str,
end_date: str,
is_present: bool,
*,
note: str | None = None,
) -> PresenceOverrideRecord:
"""Insert one override and return the record as stored.
The child must exist (validated atomically under the
connection lock). Date shape is validated strictly, and the
schema CHECK rejects end < start.
Overlap policy (Feature 05): an override for a child may not
overlap another override of the SAME child — two different
present/absent answers for one date are unresolvable. The
overlap check (inclusive boundaries) runs inside the same
transaction as the INSERT, so a racing create cannot slip a
conflicting override between the check and the write; a
conflict raises ValueError naming the conflicting override's
id and range.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
async with _connection_lock(self._database):
async with self._database.transaction():
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
# Only a well-ordered range has overlap semantics:
# inverted ranges fall through to the INSERT and hit
# the schema's end>=start CHECK, preserving the
# IntegrityError contract the matrix tests rely on.
if end_date >= start_date:
conflicting = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} "
"FROM presence_overrides "
"WHERE child_id = ? AND end_date >= ? "
"AND start_date <= ? ORDER BY id LIMIT 1",
(child_id, start_date, end_date),
)
else:
conflicting = None
if conflicting is not None:
conflict = _override_from_row(conflicting)
raise ValueError(
f"override for child {child_id} "
f"[{start_date}, {end_date}] overlaps existing "
f"override {conflict.id} "
f"[{conflict.start_date}, {conflict.end_date}] "
f"(is_present={conflict.is_present})"
)
result = await self._database.execute(
"INSERT INTO presence_overrides (child_id, "
"start_date, end_date, is_present, note) "
"VALUES (?, ?, ?, ?, ?)",
(child_id, start_date, end_date, int(is_present), note),
)
override = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE id = ?",
(result.lastrowid,),
)
assert override is not None
return _override_from_row(override)
async def list_by_child_and_range(
self,
child_id: int,
range_start: str,
range_end: str,
) -> list[PresenceOverrideRecord]:
"""Return the child's overrides overlapping [range_start, range_end].
An override overlaps the range when it ends on or after the
range starts and starts on or before the range ends (inclusive
boundary dates, matching the storage model where overrides are
closed date ranges). Ordered by start date.
"""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
rows = await self._database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE child_id = ? AND end_date >= ? AND start_date <= ? "
"ORDER BY start_date",
(child_id, range_start, range_end),
)
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(
"DELETE FROM presence_overrides WHERE id = ?",
(override_id,),
)
return result.rowcount > 0
async def read_snapshot_unlocked(
database: NestQuestDatabase,
child_ids: list[int],
range_start: str,
range_end: str,
) -> tuple[
dict[int, PresenceScheduleRecord],
dict[int, list[PresenceOverrideRecord]],
]:
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
MUST be called inside the connection lock and an open transaction, so
the schedules and overrides returned here stay coherent with every
other table the caller reads inside that same transaction (the
materialization walk's single input snapshot does exactly that). The
date range comes already validated by the caller.
Returns ``(schedules, overrides)``:
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
for every child that HAS a schedule row. A child absent from the
mapping has no schedule, i.e. is present every day (Feature 05).
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
``[range_start, range_end]`` (inclusive boundaries), ordered by start
date; a child with no matching override is absent from the mapping.
``child_ids`` must name existing children (the caller derives them
from a trusted snapshot, within the same transaction).
"""
children = sorted(set(child_ids))
if not children:
return {}, {}
placeholders = ",".join("?" for _ in children)
schedule_rows = await database.fetch_all(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
f"WHERE child_id IN ({placeholders})",
tuple(children),
)
override_rows = await database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
f"WHERE child_id IN ({placeholders}) AND end_date >= ? "
"AND start_date <= ? ORDER BY start_date",
tuple(children) + (range_start, range_end),
)
schedules = {
record.child_id: record
for record in map(_schedule_from_row, schedule_rows)
}
overrides: dict[int, list[PresenceOverrideRecord]] = {}
for row in override_rows:
record = _override_from_row(row)
overrides.setdefault(record.child_id, []).append(record)
return schedules, overrides
File diff suppressed because it is too large Load Diff
+436
View File
@@ -0,0 +1,436 @@
"""Async SQLite connection wrapper for the NestQuest integration."""
from __future__ import annotations
import asyncio
import sqlite3
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar
T = TypeVar("T")
#: Executor contract: schedule a synchronous callable on a worker
#: thread/futures pool and await its result. Mirrors the shape of
#: ``hass.async_add_executor_job`` so the integration wires it in with a
#: bare method reference, but this package never imports Home Assistant.
Executor = Callable[..., Awaitable[T]]
@dataclass(frozen=True)
class ExecutionResult:
"""Plain-data outcome of one statement.
``rowcount`` and ``lastrowid`` mirror the corresponding
:class:`sqlite3.Cursor` attributes; both are captured on the executor
thread so no sqlite3 object ever crosses back to the event loop.
"""
rowcount: int
lastrowid: int | None
class NestQuestDatabase:
"""Async wrapper around a single SQLite connection.
Every sqlite3 call is delegated to a worker through the ``executor``
callable passed at construction (mirroring the contract of
``hass.async_add_executor_job``) so the event loop is never blocked,
and only plain Python data (row tuples and
:class:`ExecutionResult`) is returned to the event loop.
Every operation is serialized through one asyncio.Lock per wrapper. A
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
interleaved coroutines cannot corrupt the transaction and concurrent
BEGINs are impossible; statements issued while a transaction is open
queue until it completes. Concurrent transactions on one wrapper are
not supported: a second ``transaction()`` entered while one is active
raises :class:`RuntimeError`. ``close()`` serializes on the same lock
and is idempotent.
Every connection-affecting executor job (connect, pragmas, statements,
transaction lifecycle and close) is awaited through the settlement
helper :meth:`_await_settled`: if the awaiting task is cancelled while
a job is in flight, the job still settles on the worker thread before
the cancellation propagates, so the wrapper lock is never released
while the executor thread is mid-job.
"""
def __init__(self, executor: Callable[..., Awaitable[T]]) -> None:
"""Initialize the wrapper around an executor callable.
``executor`` schedules a synchronous callable on a worker and
returns an awaitable of its result — the contract of
``hass.async_add_executor_job``. The integration passes that
method directly; tests pass a helper backed by
:func:`asyncio.to_thread` or an inline runner.
"""
self._executor: Callable[..., Awaitable[Any]] = executor
self._conn: sqlite3.Connection | None = None
self._rowcount: int = -1
self._lastrowid: int | None = None
self._lock: asyncio.Lock | None = None
self._lock_loop: asyncio.AbstractEventLoop | None = None
self._in_transaction = False
self._tx_task: asyncio.Task | None = None
def _get_lock(self) -> asyncio.Lock:
"""Return the per-wrapper lock, (re)bound to the running loop.
HA drives every wrapper call from one event loop, so the lock is
created once and reused; re-binding only when the running loop
differs keeps the wrapper usable from plain synchronous drivers
that run each call on a fresh loop.
"""
loop = asyncio.get_running_loop()
if self._lock is None or self._lock_loop is not loop:
self._lock = asyncio.Lock()
self._lock_loop = loop
return self._lock
@property
def connected(self) -> bool:
"""Return True while the underlying connection is open."""
return self._conn is not None
@property
def rowcount(self) -> int:
"""Row count of the most recent statement, or -1 when unset."""
return self._rowcount
@property
def lastrowid(self) -> int | None:
"""Row id of the most recent insert, or None when unset."""
return self._lastrowid
@asynccontextmanager
async def _serialized(self) -> AsyncIterator[None]:
"""Hold the wrapper lock unless the caller owns the active transaction.
A transaction holds the lock for its whole BEGIN..COMMIT span;
statements issued by the transaction's own task are already
serialized by it and must not re-acquire the non-reentrant lock.
Any other task queues on the lock until the transaction ends.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
yield
return
async with self._get_lock():
yield
async def open(self, path: Path | str) -> NestQuestDatabase:
"""Open the SQLite file with WAL journal mode and foreign keys on.
Cancellation-safe: if this call is cancelled while the connect job
is in flight, the job still settles (see :meth:`_await_settled`).
A connection the settled job created is never adopted by the
cancelled call; it is closed through a settled executor job instead
of being abandoned to garbage collection, and the cancellation
propagates afterwards, so a follow-up ``open()`` starts from a
clean state.
"""
if self._conn is not None:
return self
async with self._get_lock():
if self._conn is not None:
return self
def _open() -> sqlite3.Connection:
conn = sqlite3.connect(
path, check_same_thread=False, isolation_level=None
)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
except BaseException:
conn.close()
raise
return conn
settled, cancelled = await self._await_settled_state(
self._executor(_open)
)
if cancelled:
if isinstance(settled, sqlite3.Connection):
def _close_abandoned() -> None:
settled.close()
await self._await_settled(
self._executor(_close_abandoned)
)
raise asyncio.CancelledError()
self._conn = settled
return self
async def execute(
self, sql: str, parameters: Sequence[Any] = ()
) -> ExecutionResult:
"""Run one statement in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute() -> ExecutionResult:
cursor = conn.execute(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._executor(_execute)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def execute_many(
self, sql: str, parameters: Sequence[Sequence[Any]]
) -> ExecutionResult:
"""Run executemany in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute_many() -> ExecutionResult:
cursor = conn.executemany(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._executor(_execute_many)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def fetch_one(
self, sql: str, parameters: Sequence[Any] = ()
) -> tuple | None:
"""Run a query in the executor and return its first row, or None."""
async with self._serialized():
conn = self._require_conn()
def _fetch_one() -> tuple[tuple | None, int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
row, rowcount, lastrowid = await self._await_settled(
self._executor(_fetch_one)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return row
async def fetch_all(
self, sql: str, parameters: Sequence[Any] = ()
) -> list[tuple]:
"""Run a query in the executor and return all of its rows."""
async with self._serialized():
conn = self._require_conn()
def _fetch_all() -> tuple[list[tuple], int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
rows, rowcount, lastrowid = await self._await_settled(
self._executor(_fetch_all)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return rows
@asynccontextmanager
async def transaction(self) -> AsyncIterator[None]:
"""Run a block inside BEGIN/COMMIT, rolling back on any exception.
The wrapper lock is held for the entire BEGIN..COMMIT span, so no
other statement can interleave inside the transaction. Entering a
second transaction while one is active raises RuntimeError. A
failing COMMIT (e.g. a deferred foreign-key violation) also triggers
a ROLLBACK before the commit error propagates, so the real
connection is never left inside a stray transaction.
The lifecycle is cancellation-safe. Transaction ownership and the
wrapper lock are only released after the executor work has fully
settled, even when the owning task is cancelled mid-flight: if a
cancelled BEGIN or COMMIT still completes on the worker thread,
the corresponding ROLLBACK is shielded and awaited before any state
is cleared. A lifecycle statement that fails plus a failing
follow-up cleanup surface both errors in an ExceptionGroup.
"""
if self._in_transaction:
raise RuntimeError(
"A NestQuest transaction is already active on this "
"connection; concurrent transactions are not supported"
)
async with self._get_lock():
conn = self._require_conn()
self._in_transaction = True
self._tx_task = asyncio.current_task()
try:
def _begin() -> None:
conn.execute("BEGIN")
await self._settle_lifecycle(
conn,
self._executor(_begin),
"BEGIN did not settle cleanly and the follow-up "
"cleanup also raised; the transaction may need manual "
"cleanup",
)
try:
yield
except BaseException:
def _rollback() -> None:
conn.execute("ROLLBACK")
await self._settle_lifecycle(
conn,
self._executor(_rollback),
"ROLLBACK failed and the follow-up cleanup also "
"raised; the transaction may need manual cleanup",
)
raise
def _commit() -> None:
conn.execute("COMMIT")
await self._settle_lifecycle(
conn,
self._executor(_commit),
"COMMIT failed and the follow-up cleanup also raised; "
"the transaction may need manual cleanup",
)
finally:
self._in_transaction = False
self._tx_task = None
async def _settle_lifecycle(
self, conn: sqlite3.Connection, job: Awaitable[Any], message: str
) -> None:
"""Run one BEGIN/ROLLBACK/COMMIT job, then force the connection clean.
The job always settles first (see :meth:`_await_settled`), so a
cancelled statement that the worker thread completed anyway is
never abandoned mid-flight. If the job raised, a follow-up
ROLLBACK — itself settled — guarantees the connection ends outside
any SQLite transaction before the error propagates. When both the
job and the cleanup raise, both errors are preserved in an
ExceptionGroup instead of the original being lost.
"""
try:
await self._await_settled(job)
except BaseException as settle_error:
errors: list[BaseException] = [settle_error]
try:
await self._ensure_no_stray_transaction(conn)
except BaseException as cleanup_error:
errors.append(cleanup_error)
if len(errors) == 2:
raise BaseExceptionGroup(message, errors) from settle_error
raise
async def _ensure_no_stray_transaction(
self, conn: sqlite3.Connection
) -> None:
"""Force the connection out of any transaction via ROLLBACK.
Runs after a failed or cancelled lifecycle statement. The worker
thread may still be finishing (or may already have completed) the
very statement whose await was cancelled, so both the check and
the ROLLBACK settle in the executor before the caller releases
transaction ownership.
"""
def _cleanup() -> bool:
if conn.in_transaction:
conn.execute("ROLLBACK")
return conn.in_transaction
stray = await self._await_settled(
self._executor(_cleanup)
)
if stray:
raise RuntimeError(
"The SQLite connection is still inside a transaction after "
"cleanup; the transaction may need manual cleanup"
)
async def _await_settled(self, job: Awaitable[Any]) -> Any:
"""Await ``job``, letting it settle even if this task is cancelled.
Cancellation is observed, not swallowed: it is re-raised only once
the job has fully settled, so the caller can finalize against a
known connection state instead of abandoning in-flight work. The
job is wrapped in a task because a bare coroutine dies un-awaited
once its await is cancelled. A second cancellation during
settlement is absorbed the same way; if the job itself fails after
a cancellation, both errors surface in an ExceptionGroup.
"""
result, cancelled = await self._await_settled_state(job)
if cancelled:
raise asyncio.CancelledError()
return result
async def _await_settled_state(self, job: Awaitable[Any]) -> tuple[Any, bool]:
"""Await ``job`` to settlement and report ``(result, cancelled)``.
Same settlement guarantees as :meth:`_await_settled`, but returns
the settled outcome together with whether the awaiting task was
cancelled, so callers that must act on a settled result before
re-raising cancellation (e.g. closing an abandoned connection)
can do so without losing the result.
"""
cancelled = False
inner = asyncio.ensure_future(job)
while not inner.done():
try:
await asyncio.shield(inner)
except asyncio.CancelledError:
cancelled = True
try:
result = inner.result()
except BaseException as job_error:
if cancelled:
raise BaseExceptionGroup(
"executor job failed while settling after cancellation",
[asyncio.CancelledError(), job_error],
) from job_error
raise
return result, cancelled
async def close(self) -> None:
"""Close the connection in the executor; safe to call repeatedly.
Serialized on the wrapper lock, so an in-flight operation or open
transaction always completes before the connection is closed.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
raise RuntimeError(
"Cannot close the NestQuest database from inside its own "
"transaction; exit the transaction first"
)
async with self._get_lock():
conn, self._conn = self._conn, None
self._rowcount, self._lastrowid = -1, None
if conn is None:
return
def _close() -> None:
conn.close()
await self._await_settled(self._executor(_close))
def _require_conn(self) -> sqlite3.Connection:
if self._conn is None:
raise RuntimeError(
"The NestQuest database connection is closed; call open() first"
)
return self._conn
+190
View File
@@ -0,0 +1,190 @@
"""Transition-event payload construction for the NestQuest service paths (Feature 10).
This module is PAYLOAD CONSTRUCTION ONLY: it builds and RETURNS the
transition events (event type + payload dict) and never touches the
Home Assistant event bus or config. The caller decides how to fire
them (the integration's re-export shim wires the returned events to
``hass.bus.async_fire``).
The four documented events (design/ENTITIES-AND-SERVICES.md §3, names
in :mod:`~.const`): ``nestquest_quest_completed`` and
``nestquest_quest_uncompleted`` fire from the completion service
handlers on an actual transition (a no-op re-complete or re-uncomplete
fires nothing), and ``nestquest_child_day_complete`` fires when a
completion clears the child's whole day (quests were owed and none
remain — a zero-quest day can never fire it, matching the all-done
binary sensor's rule). ``nestquest_quest_missed`` is the documented
contract Feature 11's nightly sweep fires; nothing here builds it.
The completed-event and day-complete-event builders are split so the
shim can fire ``nestquest_quest_completed`` BEFORE it evaluates the
day-complete rule (the pre-extraction implementation fired the
completed event on the bus first, then ran the day-complete DB reads;
a failure in those reads or the ZoneInfo lookup must not suppress the
completed event). :func:`build_quest_completed_event` builds the
completed event from the instance and its child/definition only;
:func:`build_child_day_complete_event` runs the day-complete DB reads
and returns the day-complete event (or ``None``). The shim fires the
completed event, then calls the day-complete builder and fires its
result. :func:`build_quest_uncompleted_events` builds the single
uncompleted event.
Payload policy: one dict carrying ``child_id``, ``child_name``,
``instance_id``, ``quest_title``, the instance's ``window``/
``due_date``/``due_time`` so automation authors can filter without a
database lookup, and ``occurred_at`` — the event's own strict UTC
ISO-8601 timestamp. Completion payloads additionally carry
``was_on_time``. No other personal data: names and titles are the
household's own records, and nothing beyond them leaks.
Timezone policy: the day-complete check needs the HA-local "today",
which the caller passes in as a plain :class:`datetime.date`. Nothing
here reads ``hass.config.time_zone`` — the integration owns HA
timezone, the core owns the calendar arithmetic.
"""
from __future__ import annotations
import datetime
from .completion import derive_state
from .const import (
EVENT_CHILD_DAY_COMPLETE,
EVENT_QUEST_COMPLETED,
EVENT_QUEST_UNCOMPLETED,
)
from .dao_children import ChildrenDao
from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
#: explicit offset, second precision.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
async def _instance_payload(
database: NestQuestDatabase, instance_id: int, now_stamp: str
) -> dict:
"""Build the shared event payload for one quest instance."""
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
child = await ChildrenDao(database).get(instance.child_id)
definition = await QuestDefinitionsDao(database).get(
instance.definition_id
)
return {
"child_id": instance.child_id,
"child_name": child.display_name if child else None,
"instance_id": instance.id,
"quest_title": definition.title if definition else None,
"window": instance.window,
"due_date": instance.due_date,
"due_time": instance.due_time,
"occurred_at": now_stamp,
}
async def build_quest_completed_event(
database: NestQuestDatabase,
instance_id: int,
*,
was_on_time: bool | None,
) -> tuple[str, dict]:
"""Build the ``nestquest_quest_completed`` event for an actual
completion.
Called by the complete_quest service handler AFTER an actual
completion transition, with the completion's ``was_on_time`` —
computed under the completion lock and carried on the result — so
the payload never re-reads mutable latest state a racing reversal
could have replaced. A no-op re-complete never reaches here.
Returns the ``(event_type, payload)`` tuple to fire. This builder
touches NO day-complete state: it only reads the instance, its
child, and its definition, so a failure in the day-complete
evaluation (DB reads, timezone lookup in the shim) cannot suppress
the completed event. The caller fires this BEFORE asking
:func:`build_child_day_complete_event` for the day-complete event.
"""
now_stamp = _now_stamp()
payload = await _instance_payload(database, instance_id, now_stamp)
payload["was_on_time"] = was_on_time
return (EVENT_QUEST_COMPLETED, payload)
async def build_child_day_complete_event(
database: NestQuestDatabase,
completed_payload: dict,
*,
today: datetime.date,
) -> tuple[str, dict] | None:
"""Build the ``nestquest_child_day_complete`` event for a
completion that cleared the child's whole day, or ``None`` when no
day-complete event should fire.
Called by the complete_quest service handler AFTER the
``nestquest_quest_completed`` event has already been fired, with
that event's payload (so this builder reuses the same
``occurred_at`` stamp and the same ``child_id`` / ``child_name``
the completed event carried — one transition, one shared
timestamp, matching the pre-extraction implementation).
Day-complete rule (identical to the pre-extraction implementation
and to the all-done binary sensor): quests were owed today and
none remain — a zero-quest day can never fire it. Only a
completion of TODAY'S instance evaluates it: completing a
back-dated or future instance must not announce a cleared day.
``today`` is the HA-local calendar date the day-complete rule is
evaluated against; the integration reads it from
``hass.config.time_zone`` and passes it in, so this module never
touches HA config.
"""
# Only a completion of TODAY'S instance evaluates the day-complete
# rule: completing a back-dated or future instance must not
# announce a cleared day.
if completed_payload["due_date"] != today.isoformat():
return None
instances = await QuestInstancesDao(database).list_by_date_range(
completed_payload["child_id"], today.isoformat(), today.isoformat()
)
completion_events = CompletionEventsDao(database)
remaining = 0
for instance in instances:
latest_for_instance = await completion_events.get_latest_for_instance(
instance.id
)
if derive_state(instance, latest_for_instance, today) != "done":
remaining += 1
if not instances or remaining:
return None
return (
EVENT_CHILD_DAY_COMPLETE,
{
"child_id": completed_payload["child_id"],
"child_name": completed_payload["child_name"],
"quests_due": len(instances),
"quests_completed": len(instances),
"occurred_at": completed_payload["occurred_at"],
},
)
async def build_quest_uncompleted_events(
database: NestQuestDatabase, instance_id: int
) -> list[tuple[str, dict]]:
"""Build the ``nestquest_quest_uncompleted`` event for an actual reversal.
Returns a one-element list of ``(event_type, payload)`` to fire.
"""
payload = await _instance_payload(database, instance_id, _now_stamp())
return [(EVENT_QUEST_UNCOMPLETED, payload)]
@@ -0,0 +1,377 @@
"""The materialization walk (Feature 07 core).
:func:`materialize` turns the schedule rules + custody presence of the
active quest definitions into concrete ``quest_instances`` rows over a
date range. It is the pure walk over definitions x dates x assignees x
windows; HA scheduling (day-rollover triggers) is a separate later
task and lives elsewhere.
The walk is deterministic and idempotent:
- All inputs — the active definitions with their rules, assignees and
windows, AND the assignee children's presence schedules and scoped
overrides — are read inside ONE locked transaction (see
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
single coherent snapshot rather than a definition state from one
instant and a presence state from another.
- Presence is snapshotted into an immutable
:class:`~.presence.PresenceEngine` from that same coherent read.
- For each date in the range, each definition whose decoded rule
:func:`~.recurrence.occurs_on` on that date, each assignee who is
``is_present`` on that date and each declared window, one instance is
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
A config change that lands AFTER the snapshot is read but BEFORE a
tuple's write (an assignment removed, a definition or child deactivated,
a window removed) makes that tuple SKIP rather than abort the batch:
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
every materialization precondition (active definition, active child,
live assignment, declared window, and its CURRENT ``due_time``) inside
the SAME transaction as the INSERT, so the check and the write cannot be
split by a racing config edit.
The upsert is idempotent on (definition_id, child_id, due_date,
window), so re-running the materialization over the same range never
duplicates a row; a tuple whose instance is already completed is SKIPPED
(never rewritten, never raised) by
: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 + horizon_days]`` (``horizon_days`` defaults to
``DEFAULT_HORIZON_DAYS``).
HOOK (Feature 09 presence services): the presence business layer that
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
presence-state write so a child's future instances track its presence:
``set_presence_pattern`` (including clearing/setting an empty pattern),
deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
``create_presence_override``, and ``delete_presence_override``. A
schedule delete changes presence state too (the child becomes
always-present), so it must regenerate just like the others. 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
import datetime
from .const import DEFAULT_HORIZON_DAYS
from .dao_instances import QuestInstancesDao, _resolve_today
from .dao_rules import (
_validate_date,
load_materialization_input,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
)
from .db import NestQuestDatabase
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
from .recurrence import ScheduleRule, occurs_on
def _now_stamp() -> str:
"""Return the batch's shared UTC second-precision generation stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _resolve_horizon_days(horizon_days: int | None) -> int:
"""Validate a regeneration horizon and return the resolved day count.
``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool
(SQLite would happily bind ``True``/``False`` onto an int), a non-int,
or a value below 1 raises ValueError naming the field — the validation
runs BEFORE any future-instance delete so a malformed horizon can never
delete rows it then fails to rebuild. A negative horizon would
otherwise delete first and only then be rejected by
:func:`materialize`'s inverted-range check; ``0``/``False`` would
silently shrink the window to a single day.
"""
if horizon_days is None:
return DEFAULT_HORIZON_DAYS
if (
isinstance(horizon_days, bool)
or not isinstance(horizon_days, int)
or horizon_days < 1
):
raise ValueError(
f"horizon_days must be a positive integer, got {horizon_days!r}"
)
return horizon_days
def _decode_rule(snapshot) -> ScheduleRule:
"""Decode a snapshot's rule record back into a ScheduleRule.
The record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through ``ScheduleRulesDao``) preserves the coherence the snapshot
guarantees.
"""
return schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
def _build_engine(
schedules_records,
overrides_records,
) -> PresenceEngine:
"""Convert raw presence records into an immutable PresenceEngine."""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
child_id, record.anchor_date, record.pattern
)
overrides: dict[int, list[PresenceOverride]] = {}
for child_id, records in overrides_records.items():
overrides[child_id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in records
]
return PresenceEngine(schedules, overrides)
async def materialize(
database: NestQuestDatabase,
start_date: str,
end_date: str,
*,
child_ids: list[int] | None = None,
today: datetime.date | None = None,
) -> int:
"""Generate quest instances for the closed range [start_date, end_date].
``start_date`` and ``end_date`` must be strict ISO calendar dates
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
else raises ValueError naming the offending field. Returns the number
of instances upserted for the range (idempotent — a re-run refreshes
the same rows, never duplicates them).
``child_ids`` optionally scopes the walk to exactly those children:
assignees outside the list are never written, so a caller that only
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
after a presence change) cannot touch any unrelated child's rows.
When omitted (or None) the walk covers every assignee as before.
``today`` optionally pins the resolved HA-local date the walk's
no-past guard compares against; it is threaded into every
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
omitted the host clock (``datetime.date.today``) is used, which is the
historical default. A caller that computed the horizon in Home
Assistant's configured time zone MUST pass that same date here so a
time zone behind the host around midnight is not rejected as "past".
``start_date`` is clamped up to the resolved "today" (the same
``today`` anchor threaded into the no-past guard), so a caller that
passes a ``start_date`` already in the past never yields a
past-dated instance: the walk simply begins on today and skips every
earlier date. A range entirely in the past therefore produces zero
instances rather than an error, and the DAO's no-past guard remains
the authoritative backstop under the lock.
All inputs are read in ONE locked transaction; each tuple's write then
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
which re-validates the tuple and its current ``due_time`` in the same
transaction as the INSERT, so a config change that arrives mid-batch
skips the affected tuples rather than aborting the batch or leaving a
partial result.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
if end_date < start_date:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end_date!r} < {start_date!r}"
)
snapshots, schedules_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
engine = _build_engine(schedules_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids)
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows)
for snapshot in snapshots
]
generated_at = _now_stamp()
instances = QuestInstancesDao(database)
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
# Clamp a past start up to today (the HA-local anchor when the caller
# pinned one, else the host clock) so the walk can never emit a
# past-dated instance; the DAO's no-past guard remains the backstop.
# A range entirely in the past therefore yields zero instances.
today_date = _resolve_today(today)
start = max(start, today_date)
count = 0
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for definition_id, rule, assignees, windows in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if not engine.is_present(child.id, cursor):
continue
for window in windows:
written = await instances.upsert_if_valid(
definition_id,
child.id,
iso,
generated_at,
window=window.window,
due_time=window.due_time,
today=today,
)
if written is not None:
count += 1
cursor += datetime.timedelta(days=1)
return count
async def regenerate_for_definition(
database: NestQuestDatabase,
definition_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a definition's future instances after a config change.
The Feature 06 edit/assignment/activation paths call this after a
successful write so future instances track the new state: the
definition's open instances at or after today are deleted (never a
completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
then the materialization walk re-runs over the rolling horizon
``[today, today + horizon_days]`` so the (changed) rule,
assignee set and windows re-materialize against today's state.
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — so the delete cutoff and the
re-materialization range share one anchor, and the walk's
``upsert_if_valid`` no-past guard never rejects the regenerated
range. ``today`` optionally pins a caller-resolved HA-local date
(threaded into both the delete cutoff and the re-materialization)
so a household time zone behind the host around midnight stays
consistent. ``horizon_days`` sizes the re-materialization window;
when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`.
A bool, non-int, or sub-1 value raises ValueError BEFORE any delete.
Returns the number of instances the re-materialization
upserted across the whole snapshot (idempotent — other active
definitions' tuples refresh in place, never duplicate).
"""
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
start_date = today_date.isoformat()
end_date = (
today_date + datetime.timedelta(days=horizon)
).isoformat()
instances = QuestInstancesDao(database)
await instances.delete_future_uncompleted(
definition_id, start_date, today=today
)
return await materialize(database, start_date, end_date, today=today)
# 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 MUST call
# ``regenerate_for_child`` after EVERY presence-state write, so future
# instances track a child's changed presence: set_presence_pattern
# (including clearing/setting an empty pattern), deleting/clearing the
# presence schedule (PresenceSchedulesDao.delete),
# create_presence_override, and delete_presence_override. A schedule
# delete changes presence state too (the child becomes always-present),
# so it must regenerate just like the others. 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 above.
async def regenerate_for_child(
database: NestQuestDatabase,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a child's future instances after a presence change.
The Feature 09 presence services call this after every
presence-state write — set_presence_pattern (including clearing or
setting an empty pattern), deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
create_presence_override, and delete_presence_override — 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 + horizon_days]`` scoped to that child only,
so the child's now-absent/present dates re-materialize against
today's presence WITHOUT touching any other child's rows.
``child_id`` must be a plain int — bool and float are rejected
(SQLite binds a bool as 0/1 and a float would round, so a malformed
id must never mutate the wrong profile).
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — and the re-materialization range uses the
delete's returned effective cutoff (see
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
so the delete cutoff and the re-materialization window share ONE
execution-day anchor even when the call is queued across midnight.
``today`` optionally pins a caller-resolved HA-local date, threaded
into the delete cutoff and the re-materialization for the same
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
re-materialization window; when omitted it falls back to
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
raises ValueError BEFORE any delete. Returns the number of instances
the re-materialization upserted for the child across the whole
snapshot.
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
instances = QuestInstancesDao(database)
# Use the delete's returned effective cutoff as the materialize anchor,
# NOT the independently-computed "today" above. If the call is queued
# across midnight the delete clamps its cutoff to the execution-day;
# materializing from the stale "today" (now yesterday) would trip the
# walk's no-past guard and leave the child's future instances deleted
# but not rebuilt. One anchor for both keeps them consistent.
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
child_id, today_date.isoformat(), today=today
)
start = datetime.date.fromisoformat(effective_cutoff)
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
return await materialize(
database, start.isoformat(), end_date, child_ids=[child_id], today=today
)
@@ -0,0 +1,593 @@
"""Versioned schema migration runner for the NestQuest SQLite database.
The runner owns one metadata table, ``nestquest_schema_version``, holding
a single row with the schema version the database file currently
reflects. Migrations are declared as an ordered list: entry ``n``
upgrades version ``n-1`` to version ``n``. Applying a migration means
running its DDL inside one transaction and stamping the new version in
that same transaction, so a database never persists a half-applied
migration: any failure rolls the statements and the version stamp back
together, leaving the file at its prior version.
Version 0 means "the file exists but carries no NestQuest schema yet"
— a fresh file, or a file this integration has never written. Starting
the runner against version 0 applies migration 1, which applies the
full v1 DDL from :mod:`.schema` and stamps version 1.
Migration 1 was rewritten pre-release (D-007): nothing was ever
shipped, so the v1 DDL now creates the ``quest_*`` tables directly
instead of the legacy ``task_*`` spellings, and no compatibility shim
exists. Dev machines that already hold a version-1 database with
``task_*`` tables are handled by migration 2, which detects the legacy
table names and ``ALTER TABLE ... RENAME TO``s them to their current
names before anything else assumes the new model. Migration 3 brings
pre-D-008 databases to the multi-assignee model: it creates
``quest_definition_assignees`` and, when ``quest_definitions`` still
carries a single ``child_id`` column, rebuilds the table without it,
copying each definition's assignee across. Migration 4 adds the
windows table, migration 5 rebuilds ``quest_instances`` onto the
widened (definition_id, child_id, due_date, window) key, and migration
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
column. Migration 7 adds the generic ``nestquest_meta_state``
key/value table for integration-owned bookkeeping (Feature 11's
missed-sweep watermark). Each callable migration runs inside its own
transaction
together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together.
Migration entries are either an ordered sequence of SQL statements or
an async callable taking ``(database, target_version)``. Callables
exist for steps whose SQL depends on the database's current state (a
legacy rename only fires when a legacy table is present; a rebuild only
when the old column shape is present) and own their transaction so they
can bracket it with connection pragmas the runner's uniform transaction
cannot express.
Each migration's statements and its version stamp run inside ONE
transaction, and the stamp is written as DELETE + INSERT rather than
UPDATE because a version-0 database has no version row to update yet.
An UPDATE form would silently match zero rows there and leave the
database migrated but unstamped — reporting version 0 forever, and
re-applying migration 1 on every startup. The DELETE+INSERT shape is
also what makes the stamp a replace, so the row never duplicates.
Idempotence across restarts: a database already at the latest version
has no pending migrations, so the runner opens a read-only check, finds
nothing to do, and leaves the file byte-untouched (no write transaction
is opened, so no WAL churn).
Migration list policy: entries are append-only. Once shipped, a
migration's SQL must never be edited — an old database applying it for
the first time and a fresh database would then diverge. Fix-forward
with a new migration instead. The version stamp lives in the same
SQLite file as the data it describes, so restoring a backup restores
its matching schema version with it.
Corrupt or missing files are not this module's concern: startup
handling of those cases is task d5bca74b, which decides whether to
open fresh or raise ConfigEntryNotReady. This runner assumes it is
handed an open, working connection.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable, Sequence
from typing import Union
from .db import NestQuestDatabase
from .schema import (
COMPLETION_EVENTS_TABLE_SQL,
QUEST_DEFINITIONS_TABLE_SQL,
QUEST_INSTANCES_TABLE_SQL,
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
SCHEMA_V7_META_STATE_DDL,
)
LOGGER = logging.getLogger(__name__)
#: One migration entry: either an ordered sequence of SQL statements or
#: an async callable taking ``(database, target_version)`` that manages
#: its own transaction and stamps via :func:`stamp_schema_version`.
MigrationStep = Union[
Sequence[str],
Callable[[NestQuestDatabase, int], Awaitable[None]],
]
#: Metadata table holding the applied schema version. Prefixed with the
#: integration domain so a config directory shared with other tools can
#: never collide with a foreign ``schema_version`` table.
VERSION_TABLE = "nestquest_schema_version"
#: DDL for the version metadata table. The singleton-row invariant is
#: enforced by the schema itself: ``id`` is fixed at 1 by CHECK, keyed
#: PRIMARY KEY, so the table can never hold a second row, and the
#: stamp write is an UPDATE (no DELETE+INSERT needed). ``version`` is
#: a positive integer: version 0 means "no row" and is never stored,
#: so a negative or zero stored value is corruption, not a state the
#: runner can act on.
VERSION_TABLE_DDL = f"""
CREATE TABLE IF NOT EXISTS {VERSION_TABLE} (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL CHECK (version >= 1)
)
"""
#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades
#: version ``n-1`` to version ``n``.
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
#: The assignees table DDL, shared with migration 1 via the schema
#: module so a rebuilt and a freshly created table cannot drift.
_ASSIGNEES_TABLE_DDL: str = SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL[0]
#: Legacy (pre-D-007) table name -> current table name. Order matters:
#: the parent table renames first so the child's foreign key reference
#: is rewritten while its parent's new name is already in place.
LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = (
("task_definitions", "quest_definitions"),
("task_instances", "quest_instances"),
)
async def stamp_schema_version(
database: NestQuestDatabase, version: int
) -> None:
"""Write the schema version stamp (inside the caller's transaction).
INSERT keyed on the singleton row (id = 1) with an upsert: a fresh
file has no row yet, so UPDATE alone would silently match zero rows
there and leave a migrated-but-unstamped database.
"""
await database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) "
"VALUES (1, ?) "
"ON CONFLICT (id) DO UPDATE SET version = excluded.version",
(version,),
)
async def _table_has_column(
database: NestQuestDatabase, table: str, column: str
) -> bool:
"""Return True when ``table`` currently carries ``column``."""
rows = await database.fetch_all(f"PRAGMA table_info({table})")
return any(row[1] == column for row in rows)
#: Window assigned to instances migrated from the pre-window instance
#: shape (D-008). Pre-window rows were generated for whole-day
#: obligations; the morning window is the documented default landing
#: spot, and future dates are regenerated with real window declarations
#: by materialization (delete_future_uncompleted + re-run).
LEGACY_MIGRATED_WINDOW = "morning"
async def _rename_legacy_task_tables(
database: NestQuestDatabase, target_version: int
) -> None:
"""Rename any surviving ``task_*`` tables to their ``quest_*`` names.
Dev-machines-only path (D-007): a database stamped version 1 by the
pre-rename runner holds ``task_definitions``/``task_instances``.
Each legacy name still present is renamed; fresh databases find
nothing and this step is a no-op. Modern SQLite rewrites foreign
key clauses in other tables to follow the rename, so the renamed
schema is indistinguishable from a freshly created one apart from
internal autoindex names. Runs in its own transaction together
with the version stamp, so a crash rolls both back.
"""
async with database.transaction():
for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES:
row = await database.fetch_one(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name = ?",
(legacy_name,),
)
if row is not None:
await database.execute(
f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"'
)
LOGGER.info(
"Renamed legacy table %s to %s",
legacy_name,
current_name,
)
await stamp_schema_version(database, target_version)
async def _add_definition_assignees(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring definitions to the multi-assignee model (D-008).
Two shapes arrive here:
- Fresh or renamed databases whose ``quest_definitions`` already
matches the current canonical DDL (no ``child_id`` column): only
the assignees table needs creating (``IF NOT EXISTS`` is a no-op
when migration 1 already made it).
- Databases stamped at version 2 whose ``quest_definitions`` still
carries the single ``child_id`` column: the table is rebuilt via
SQLite's canonical procedure — create the new table, copy rows,
move each definition's assignee into
``quest_definition_assignees``, drop the old table, rename — so
``DROP COLUMN``'s foreign-key restriction never applies and no
definition or instance row is lost.
The copy runs with ``foreign_keys = OFF`` because dropping the old
table under enforcement would fire the implicit DELETE and violate
the ``quest_instances`` foreign key; ``PRAGMA foreign_key_check``
runs inside the same transaction before the stamp, so a corrupt
copy aborts the whole migration instead of persisting. The pragma
is a connection-level no-op inside a transaction, hence the
off/transaction/on bracket.
"""
legacy_child_id = await _table_has_column(
database, "quest_definitions", "child_id"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
await database.execute(_ASSIGNEES_TABLE_DDL)
if legacy_child_id:
await database.execute(
"CREATE TABLE quest_definitions_rebuilt "
f"{QUEST_DEFINITIONS_TABLE_SQL}"
)
await database.execute(
"INSERT INTO quest_definitions_rebuilt "
"(id, title, description, icon, schedule_rule_id, "
"due_time, is_active, created_at) "
"SELECT id, title, description, icon, "
"schedule_rule_id, due_time, is_active, created_at "
"FROM quest_definitions ORDER BY id"
)
await database.execute(
"INSERT OR IGNORE INTO quest_definition_assignees "
"(definition_id, child_id) "
"SELECT id, child_id FROM quest_definitions"
)
await database.execute("DROP TABLE quest_definitions")
await database.execute(
"ALTER TABLE quest_definitions_rebuilt "
"RENAME TO quest_definitions"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"definition rebuild produced foreign-key "
f"violations: {violations!r}"
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
async def _widen_instance_key(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring ``quest_instances`` to the D-008 instance key.
Databases whose instance table still carries the pre-window shape
(no ``window`` column, UNIQUE on (definition_id, due_date)) are
rebuilt via SQLite's canonical procedure into the widened shape —
UNIQUE (definition_id, child_id, due_date, window) — with every
migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing
ids (and the completion events referencing them) survive intact.
The old rows were unique per (definition, due_date), so pinning one
window cannot collide with the widened key. Fresh databases whose
table already matches the canonical shape only stamp.
The rebuild runs with ``foreign_keys = OFF`` (dropping the old
table under enforcement would fire the implicit DELETE and violate
the ``completion_events`` foreign key) and verifies
``PRAGMA foreign_key_check`` inside the transaction before the
stamp, so a corrupt copy aborts the whole migration.
"""
legacy_shape = not await _table_has_column(
database, "quest_instances", "window"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
if legacy_shape:
await database.execute(
"CREATE TABLE quest_instances_rebuilt "
f"{QUEST_INSTANCES_TABLE_SQL}"
)
await database.execute(
"INSERT INTO quest_instances_rebuilt "
"(id, definition_id, child_id, window, due_date, "
"due_time, generated_at) "
"SELECT id, definition_id, child_id, ?, due_date, "
"due_time, generated_at "
"FROM quest_instances ORDER BY id",
(LEGACY_MIGRATED_WINDOW,),
)
await database.execute("DROP TABLE quest_instances")
await database.execute(
"ALTER TABLE quest_instances_rebuilt "
"RENAME TO quest_instances"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"instance rebuild produced foreign-key "
f"violations: {violations!r}"
)
LOGGER.info(
"Widened quest_instances key; migrated rows pinned "
"to the %r window",
LEGACY_MIGRATED_WINDOW,
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
async def _add_actor_child_id(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring ``completion_events`` to the D-008 actor-child shape.
The ``actor_child_id`` column records which panel profile was
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
because the canonical shape also carries an actor-pair coherence
CHECK (panel events require it, user events forbid it), and CHECK
constraints cannot be added to an existing table. Databases with
the legacy shape are therefore rebuilt via SQLite's canonical
procedure, backfilling each legacy panel row's ``actor_child_id``
to ``child_id`` — pre-D-008 panel completions were always recorded
against the tapped child themselves — while admin rows stay NULL.
Row ids are preserved so ``quest_instances`` references and the
append-only history stay intact. Fresh databases already match
the canonical shape and only stamp.
The rebuild runs with ``foreign_keys = OFF`` and verifies
``PRAGMA foreign_key_check`` inside the transaction before the
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
completion_events table keeps its append-only guarantee.
"""
legacy_shape = not await _table_has_column(
database, "completion_events", "actor_child_id"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
if legacy_shape:
await database.execute(
"CREATE TABLE completion_events_rebuilt "
f"{COMPLETION_EVENTS_TABLE_SQL}"
)
await database.execute(
"INSERT INTO completion_events_rebuilt "
"(id, instance_id, child_id, event_type, actor_source, "
"actor_user_id, actor_child_id, occurred_at, "
"was_on_time) "
"SELECT id, instance_id, child_id, event_type, "
"actor_source, actor_user_id, "
"CASE WHEN actor_source = 'panel' THEN child_id "
"ELSE NULL END, "
"occurred_at, was_on_time "
"FROM completion_events ORDER BY id"
)
await database.execute("DROP TABLE completion_events")
await database.execute(
"ALTER TABLE completion_events_rebuilt "
"RENAME TO completion_events"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"completion_events rebuild produced foreign-key "
f"violations: {violations!r}"
)
LOGGER.info(
"Added actor_child_id to completion_events; legacy "
"panel rows backfilled to their own child_id"
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
#: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead. (Migration 1's content was
#: rewritten pre-release per D-007 — no version 1 database shipped.)
MIGRATIONS: Sequence[MigrationStep] = [
MIGRATION_1_V1_DDL,
_rename_legacy_task_tables,
_add_definition_assignees,
# Migration 4 (D-008): the windows table is a plain new table — a
# CREATE IF NOT EXISTS suffices for both fresh databases (which
# already made it in migration 1) and pre-window databases.
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
# Migration 5 (D-008): rebuild quest_instances onto the widened
# (definition_id, child_id, due_date, window) key.
_widen_instance_key,
# Migration 6 (D-008): rebuild completion_events with the
# actor_child_id column and its actor-pair coherence CHECK,
# backfilling legacy panel rows to their own child_id.
_add_actor_child_id,
# Migration 7 (Feature 11): the generic meta_state key/value table
# — a plain new table, so CREATE IF NOT EXISTS suffices for both
# fresh databases (which already made it in migration 1) and
# pre-v7 databases. Strictly additive; no existing table changes.
list(SCHEMA_V7_META_STATE_DDL),
]
async def _create_version_table(database: NestQuestDatabase) -> None:
"""Create the version metadata table if it does not exist.
The table is created before any version check: it is the runner's
own bookkeeping, and creating it is idempotent. The DDL enforces
the singleton-row invariant (``id`` fixed at 1) and a positive
integer version at the database level.
"""
await database.execute(VERSION_TABLE_DDL)
async def read_schema_version(database: NestQuestDatabase) -> int:
"""Return the schema version recorded in the database.
Returns 0 when the version table or its row is missing — the
"fresh file" case. A stored version higher than the runner knows
is surfaced as-is; the caller decides how to treat a future version.
Reading is deliberately passive: a missing table raises, it is not
swallowed into 0, because :func:`apply_migrations` always creates
the table before reading and a direct reader hitting "no such
table" is a caller bug worth surfacing, not a fresh file.
"""
row = await database.fetch_one(
f"SELECT version FROM {VERSION_TABLE} LIMIT 1"
)
if row is None:
return 0
version = row[0]
if not isinstance(version, int) or isinstance(version, bool):
raise RuntimeError(
f"Schema version metadata is not an integer: {version!r}"
)
if version < 1:
raise RuntimeError(
f"Schema version metadata is invalid (must be >= 1): {version!r}"
)
return version
#: One asyncio.Lock per connection wrapper, created lazily and bound to
#: the running loop, serializing whole apply_migrations runs so two
#: concurrent callers on one connection queue instead of colliding on
#: the wrapper's single-transaction rule.
_MIGRATION_LOCKS: dict[int, asyncio.Lock] = {}
def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the migration lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _MIGRATION_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_MIGRATION_LOCKS[key] = lock
return lock
async def apply_migrations(
database: NestQuestDatabase,
migrations: Sequence[MigrationStep] = MIGRATIONS,
) -> int:
"""Bring the database schema up to the latest version.
Reads the current version, applies every pending migration in order
— each inside one transaction that also stamps the new version —
and returns the resulting version. Applying zero pending
migrations (database already current) is a no-op that logs at debug
level and opens no write transaction.
The version is re-read inside each migration transaction before
deciding to apply it: the outer read is advisory only (loop bounds,
no-op fast path), and on one shared connection two interleaved
runners would otherwise both read the same version and both apply
a non-idempotent migration. Under the transaction's lock the
second runner sees the first runner's stamp and skips.
Runners on one connection are serialized by an asyncio lock held
for the whole decide-and-apply span: NestQuestDatabase rejects a
second transaction while one is active (RuntimeError), so
concurrent callers must queue, and the lock makes that queueing
explicit. The loser of the race re-reads the version once it
acquires the lock, sees the winner's stamp, and applies nothing.
Raises whatever the failing migration raised, with the database
rolled back to its prior version and prior rows untouched.
"""
async with _migration_lock(database):
await _create_version_table(database)
current = await read_schema_version(database)
latest = len(migrations)
if current > latest:
raise RuntimeError(
f"Database schema version {current} is newer than this "
f"integration understands (latest known: {latest}); "
"downgrades are not supported"
)
if current == latest:
LOGGER.debug(
"NestQuest schema already at version %d; "
"no migrations to apply",
current,
)
return current
for target in range(current + 1, latest + 1):
applied = False
step = migrations[target - 1]
if callable(step):
# Callable migrations own their transaction: some steps
# (table rebuilds) must run connection pragmas such as
# ``foreign_keys = OFF`` BEFORE opening one, which the
# runner's uniform transaction cannot express, and they
# stamp inside their own transaction so a failure rolls
# the statements and the version stamp back together.
# The version is re-read here under the migration lock:
# runners on one connection are serialized by it, so
# the version cannot change between this read and the
# callable's commit.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
f"Database schema version {in_tx_version} is newer "
f"than this integration understands (latest known: "
f"{latest}); downgrades are not supported"
)
if in_tx_version < target:
await step(database, target)
applied = True
else:
async with database.transaction():
# Re-read the version INSIDE the write transaction:
# the outer read was advisory, and the wrapper
# serializes individual statements, not the
# read-decide-apply decision. Under the
# transaction's lock the version cannot change
# between the read and the stamp, and the loser of
# an interleaved race (a queued second caller
# re-running apply_migrations after the winner
# already migrated) re-reads here, sees the
# winner's stamp, and skips instead of re-applying
# a non-idempotent migration with a stale version.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
f"Database schema version {in_tx_version} is "
f"newer than this integration understands "
f"(latest known: {latest}); downgrades are "
f"not supported"
)
if in_tx_version < target:
for sql in step:
await database.execute(sql)
await stamp_schema_version(database, target)
applied = True
applied_statements = len(step)
if applied:
LOGGER.info(
"NestQuest schema migrated to version %d",
target,
)
return latest
@@ -0,0 +1,477 @@
"""The presence and custody model (Feature 05).
PURE module: no Home Assistant imports, no database access, no
timezone logic — it operates on plain calendar dates only (feature
guardrail). The later engine (cycle position, ``is_present``,
overrides, previews) builds directly on this model.
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
week is this child at this house?" for an N-week repeating custody
pattern. Two of the household's three children follow a two-week
rotation; a child with NO schedule row is present every day (that
default lives in the engine, not here).
Pattern encoding: the model's canonical serialization is the SAME
pipe-separated CSV the database stores (see schema.py's
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
empty segment means absent every day of that week. Encoding sorts
each segment's weekdays and uses the exact segment order, so
``decode(encode(schedule))`` round-trips losslessly and two schedules
with equal patterns produce equal strings.
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
computed from anchor-date arithmetic ONLY — never ISO week numbers or
week parity (D-004): 53-week years silently invert parity on January
1st. The arithmetic lives in the engine task; this module validates
that the anchor is a strict calendar date.
Validation policy: construction is total — every reject happens in
``__init__`` so no invalid schedule can ever exist:
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
the database CHECK (schema.py) and keeps anchor arithmetic and the
encoded pattern human-readable; revisit both together if a longer
cycle is ever needed.
- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly
(no gaps, no extra weeks) with sets of integers 0..6 (bools
rejected: True would silently mean Monday).
- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a
``datetime.date``).
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from types import MappingProxyType
#: The schema's documented cap for cycle_length_weeks (schema.py keeps
#: the pattern human-readable and the anchor arithmetic small).
MAX_CYCLE_LENGTH_WEEKS = 4
_DATE_FORMAT = "%Y-%m-%d"
def _parse_date(value: object, field_name: str) -> datetime.date:
"""Accept a datetime.date or a strict YYYY-MM-DD string.
``datetime.datetime`` is rejected explicitly: it SUBCLASSES
datetime.date, so a bare isinstance check would store a value with
a time component and every later calendar-date comparison (e.g.
``PresenceOverride.covers``) would raise TypeError.
"""
if isinstance(value, datetime.datetime):
raise ValueError(
f"{field_name} must be a plain calendar date, not a "
f"datetime with a time component: {value!r}"
)
if isinstance(value, datetime.date):
return value
if not isinstance(value, str):
raise ValueError(
f"{field_name} must be a datetime.date or an ISO date string, "
f"got {value!r}"
)
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise ValueError(
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise ValueError(
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
)
return parsed
def _validate_week_index(week: object, cycle_length_weeks: int) -> int:
"""Reject non-int week indices (bools included) and out-of-cycle."""
if isinstance(week, bool) or not isinstance(week, int):
raise ValueError(
f"pattern week index must be an integer, got {week!r}"
)
if not 0 <= week < cycle_length_weeks:
raise ValueError(
f"pattern week index must be in 0..{cycle_length_weeks - 1}, "
f"got {week}"
)
return week
def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
"""Validate one week's present-weekday set."""
if isinstance(weekdays, (str, bytes)) or weekdays is None:
raise ValueError(
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
f"got {weekdays!r}"
)
try:
members = list(weekdays)
except TypeError:
raise ValueError(
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
f"got {weekdays!r}"
) from None
for member in members:
if isinstance(member, bool) or not isinstance(member, int):
raise ValueError(
f"pattern[{week}] weekday must be an int 0..6, got {member!r}"
)
if not 0 <= member <= 6:
raise ValueError(
f"pattern[{week}] weekday must be in 0..6 (Monday=0), "
f"got {member}"
)
return frozenset(members)
def _validate_cycle_length(value: object) -> int:
"""Reject non-int and out-of-range cycle lengths (bools included)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(
f"cycle_length_weeks must be an integer, got {value!r}"
)
if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS:
raise ValueError(
f"cycle_length_weeks must be between 1 and "
f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}"
)
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools would silently address child 1)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
@dataclass(frozen=True, init=False)
class PresenceSchedule:
"""One child's N-week repeating presence pattern.
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
to the set of present weekdays (ints, Monday=0). A week with an
empty set means the child is absent every day of that week — which
is distinct from having no schedule at all (the engine treats a
missing schedule as present every day).
"""
child_id: int
cycle_length_weeks: int
anchor_date: datetime.date
pattern: MappingProxyType # week index -> frozenset of weekdays
def __init__(
self,
child_id: int,
cycle_length_weeks: int,
anchor_date: object,
pattern: dict[int, object],
) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id))
cycle = _validate_cycle_length(cycle_length_weeks)
object.__setattr__(self, "cycle_length_weeks", cycle)
object.__setattr__(
self, "anchor_date", _parse_date(anchor_date, "anchor_date")
)
if not isinstance(pattern, dict):
raise ValueError(
"pattern must map week index to a set of present "
f"weekdays, got {pattern!r}"
)
normalised: dict[int, frozenset[int]] = {}
for week, weekdays in pattern.items():
index = _validate_week_index(week, cycle)
normalised[index] = _validate_weekday_set(index, weekdays)
missing = [
week for week in range(cycle) if week not in normalised
]
if missing:
raise ValueError(
f"pattern must cover week indices 0..{cycle - 1}; "
f"missing {missing}"
)
# Frozen must mean frozen: a bare dict would let callers inject
# unvalidated weekdays (or drop required weeks) AFTER
# construction, bypassing total validation. The mapping
# proxy exposes the validated contents read-only; the
# per-week sets are already frozensets.
object.__setattr__(self, "pattern", MappingProxyType(normalised))
def encode(self) -> str:
"""Serialize to the database's pipe-separated CSV shape.
Segments appear in week order; each segment is the week's
present weekdays as a sorted comma-separated list (an empty set
encodes as an empty segment). Two schedules with equal
patterns encode identically.
"""
segments = [
",".join(str(day) for day in sorted(self.pattern[week]))
for week in range(self.cycle_length_weeks)
]
return "|".join(segments)
@classmethod
def decode(
cls,
child_id: int,
anchor_date: object,
encoded: str,
) -> "PresenceSchedule":
"""Build a schedule from its encoded pattern.
The cycle length is derived from the segment count, mirroring
the database's segment-count CHECK. Decoding is strict: wrong
shapes, non-numeric or out-of-range weekdays are rejected —
the database CHECKs make such rows unrepresentable, and this
is the same contract on the model side. A segment count above
the documented cycle cap is rejected by the ordinary
construction validation.
"""
if not isinstance(encoded, str):
raise ValueError(
f"encoded pattern must be a string, got {encoded!r}"
)
segments = encoded.split("|")
pattern: dict[int, set[int]] = {}
for week, segment in enumerate(segments):
weekdays: set[int] = set()
if segment:
for part in segment.split(","):
if len(part) != 1 or part not in "0123456":
raise ValueError(
f"encoded segment {week} carries an invalid "
f"weekday {part!r} (single digits 0-6 only)"
)
weekdays.add(int(part))
pattern[week] = weekdays
return cls(child_id, len(segments), anchor_date, pattern)
@dataclass(frozen=True, init=False)
class PresenceOverride:
"""A date-specific present/absent flag beating any pattern.
Covers a single date (``end_date == start_date``) or an inclusive
range. A concrete range is the storage shape
(``presence_overrides.end_date`` is NOT NULL), so the model
requires both bounds and validates start <= end. Construction and
validation live here so the engine can trust every instance.
"""
child_id: int
start_date: datetime.date
end_date: datetime.date
is_present: bool
note: str | None
def __init__(
self,
child_id: int,
start_date: object,
end_date: object,
is_present: bool,
note: str | None = None,
) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id))
start = _parse_date(start_date, "start_date")
end = _parse_date(end_date, "end_date")
if end < start:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end.isoformat()} < {start.isoformat()}"
)
object.__setattr__(self, "start_date", start)
object.__setattr__(self, "end_date", end)
if not isinstance(is_present, bool):
raise ValueError(
f"is_present must be a real bool, got {is_present!r}"
)
object.__setattr__(self, "is_present", is_present)
if note is not None and not isinstance(note, str):
raise ValueError(f"note must be a string or None, got {note!r}")
object.__setattr__(self, "note", note)
def covers(self, date: datetime.date) -> bool:
"""Return True when the override applies to ``date``."""
return self.start_date <= date <= self.end_date
def cycle_week_index(
schedule: PresenceSchedule, target_date: object
) -> int:
"""Return the cycle week index ``target_date`` falls in.
Anchor-date arithmetic ONLY (D-004): ``((target_date -
anchor_date).days // 7) % cycle_length_weeks``. Python's floor
division makes dates BEFORE the anchor behave correctly — they
walk the cycle backwards (day -1 is the last week of the cycle,
day -8 wraps one further) instead of inverting, which is exactly
what ISO week parity does wrong in 53-week years. The modulo of
a negative in Python is non-negative, so the index is always a
valid week index.
"""
target = _parse_date(target_date, "target_date")
days = (target - schedule.anchor_date).days
return (days // 7) % schedule.cycle_length_weeks
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
#: years of daily checks. A child absent every day (an all-absent
#: pattern with a blocking override) would otherwise loop forever; the
#: cap bounds the scan and surfaces the situation as an error instead.
MAX_PREVIEW_SCAN_DAYS = 366 * 2
class PresenceEngine:
"""Answers "is this child at this house on this date?".
Pure and side-effect-free: built from an immutable snapshot of the
children's schedules (``child_id -> PresenceSchedule``), it never
touches the database — callers build a snapshot from the DAO layer
and pass it in. A child with NO schedule entry is present every
day (the third household child's case); a child WITH a schedule is
present exactly when the date's weekday is in that schedule's
pattern for the date's cycle week index (anchor-date arithmetic —
:func:`cycle_week_index`, never ISO week parity).
"""
def __init__(
self,
schedules: dict[int, PresenceSchedule],
overrides: dict[int, list[PresenceOverride]] | None = None,
) -> None:
if not isinstance(schedules, dict):
raise ValueError(
f"schedules must map child_id to PresenceSchedule, got "
f"{schedules!r}"
)
for child_id, schedule in schedules.items():
# Validate the KEY as a model child id too: True and 1.0
# compare equal to 1, so {True: schedule(1)} would be
# accepted and then behave differently on lookup.
_validate_child_id(child_id)
if not isinstance(schedule, PresenceSchedule):
raise ValueError(
f"schedules[{child_id!r}] must be a PresenceSchedule, "
f"got {schedule!r}"
)
if schedule.child_id != child_id:
raise ValueError(
f"schedules is keyed {child_id!r} but holds the "
f"schedule of child {schedule.child_id}"
)
if overrides is None:
overrides = {}
if not isinstance(overrides, dict):
raise ValueError(
"overrides must map child_id to a list of "
f"PresenceOverride, got {overrides!r}"
)
for child_id, entries in overrides.items():
_validate_child_id(child_id)
if not isinstance(entries, list):
raise ValueError(
f"overrides[{child_id!r}] must be a list of "
f"PresenceOverride, got {entries!r}"
)
for entry in entries:
if not isinstance(entry, PresenceOverride):
raise ValueError(
f"overrides[{child_id!r}] entries must be "
f"PresenceOverride, got {entry!r}"
)
if entry.child_id != child_id:
raise ValueError(
f"overrides is keyed {child_id!r} but holds an "
f"override of child {entry.child_id}"
)
# Read-only snapshot: reassignment is refused and no mutator
# exists, so a built engine answers consistently for its
# lifetime. The overrides lists are copied to immutable
# tuples — a shallow mapping copy alone would leave caller-
# owned lists reachable through _overrides, and mutating one
# later would change is_present results.
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
object.__setattr__(
self,
"_overrides",
MappingProxyType(
{child_id: tuple(entries) for child_id, entries in overrides.items()}
),
)
def is_present(self, child_id: object, date: object) -> bool:
"""Return whether ``child_id`` is present on ``date``.
Overrides take precedence over the repeating pattern: when an
override covers the date, its flag IS the answer — including
for a child with no schedule at all. The DAO layer guarantees
a child's overrides never overlap; if a snapshot still carries
two covering the same date, the one with the LATEST start date
wins (deterministic, and matches "the most specific swap wins").
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
target = _parse_date(date, "date")
entries = self._overrides.get(child_id) or []
covering = [
entry for entry in entries if entry.covers(target)
]
if covering:
winner = max(covering, key=lambda entry: entry.start_date)
return winner.is_present
schedule = self._schedules.get(child_id)
if schedule is None:
# No schedule at all: present every day (household default).
return True
week = cycle_week_index(schedule, target)
return target.weekday() in schedule.pattern[week]
def next_present_dates(
self,
child_id: object,
start_date: object,
count: int,
) -> list[datetime.date]:
"""Return the next ``count`` dates the child is present.
Walks forward from ``start_date`` (INCLUSIVE — a parent
previewing "starting Monday" expects Monday itself when the
child is present) checking :meth:`is_present` for every day,
so overrides and the pattern are honoured identically. The
admin Schedule tab renders this preview so a parent can confirm
a custody pattern is right before saving it.
A child absent every scanned day raises ValueError after the
two-year search cap — an always-absent child would otherwise
loop forever. ``count`` must be a positive integer.
"""
_validate_child_id(child_id)
start = _parse_date(start_date, "start_date")
if isinstance(count, bool) or not isinstance(count, int):
raise ValueError(f"count must be an integer, got {count!r}")
if count < 1:
raise ValueError(f"count must be at least 1, got {count}")
found: list[datetime.date] = []
day = start
for _ in range(MAX_PREVIEW_SCAN_DAYS):
if self.is_present(child_id, day):
found.append(day)
if len(found) == count:
return found
day += datetime.timedelta(days=1)
raise ValueError(
f"child {child_id} is not present on any of the "
f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; "
f"{count} present day(s) cannot be previewed"
)
def __setattr__(self, name: object, value: object) -> None:
raise AttributeError(
"PresenceEngine is an immutable snapshot; build a new engine "
"instead of mutating this one"
)
@@ -0,0 +1,603 @@
"""Business layer for creating and editing quest definitions (Feature 06).
Sits between the Feature 09 service gate and the typed
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path,
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
Validation policy mirrors :mod:`.children`:
- ``title`` is required and trimmed; empty or whitespace-only raises
ValueError.
- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
(its constructor is total — rule validation is NOT re-implemented
here).
- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``);
every id is a plain int (bools and floats rejected, since SQLite would
bind them onto a real child) and must reference an existing ACTIVE
child (the DAO enforces the active/exists check inside the same
transaction). Duplicate ids are rejected here, before the composite
primary key could surface them as an :class:`sqlite3.IntegrityError`.
- ``windows`` must be a non-empty list (strictly a ``list``); each entry
is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare
name (no due time) or a ``(name, due_time)`` pair whose due time is a
strict 24-hour HH:MM string. Duplicate window names are rejected
here, before the composite primary key could surface them as an
:class:`sqlite3.IntegrityError`.
The rule, definition, assignees and windows are persisted in ONE
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
so a rejected or failed create persists nothing. The definition-level
``due_time`` column is superseded by per-window due times (D-008) and is
never set here. ``created_at`` is stamped UTC ISO-8601 at second
precision, matching :mod:`.children`.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import ChildRecord, _connection_lock
from .dao_rules import (
QuestDefinitionRecord,
QuestDefinitionSnapshot,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
ScheduleRuleStorage,
ScheduleRulesDao,
_UNSET,
_validate_date,
_validate_time,
_validate_window,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
from .materialize import regenerate_for_definition
from .recurrence import ScheduleRule, occurs_on
@dataclass(frozen=True)
class CreatedQuestDefinition:
"""The persisted result of a create, edit or activation change.
``definition`` is the stored row; ``rule`` is the schedule rule
decoded back through the storage mapping; ``assignees`` and
``windows`` are the definition's linked child records and window
declarations, read through the DAO.
"""
definition: QuestDefinitionRecord
rule: ScheduleRule
assignees: list[ChildRecord]
windows: list[QuestDefinitionWindowRecord]
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_text(
value: object, field: str, *, required: bool = False
) -> str | None:
"""Validate a free-text field and return it trimmed.
``None`` raises when ``required`` and otherwise returns None. A
non-string value is rejected; an empty result is rejected for
required fields.
"""
if value is None:
if required:
raise ValueError(f"{field} is required")
return None
if not isinstance(value, str):
raise ValueError(f"{field} must be a string, got {value!r}")
trimmed = value.strip()
if required and not trimmed:
raise ValueError(f"{field} is required")
return trimmed
def _validate_assignee_ids(value: object) -> list[int]:
"""Return the assignee ids as plain ints, or raise naming the field.
The argument must be a non-empty list — strictly a ``list``: tuples
and other sequences are rejected. Each entry must be a real int
(bools and floats rejected) so SQLite cannot bind ``True`` onto
child 1 or a float onto a neighbouring profile.
"""
if type(value) is not list:
raise ValueError(
f"assignee_child_ids must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("assignee_child_ids must not be empty")
ids: list[int] = []
for entry in value:
if isinstance(entry, bool) or not isinstance(entry, int):
raise ValueError(
"assignee_child_ids must contain integer child ids, "
f"got {entry!r}"
)
ids.append(entry)
if len(set(ids)) != len(ids):
raise ValueError(
"assignee_child_ids must not contain duplicate child ids"
)
return ids
def _normalize_windows(
value: object,
) -> list[tuple[str, str | None]]:
"""Normalize the windows argument to ``(name, due_time)`` pairs.
The outer argument must be a non-empty list — strictly a ``list``:
tuples and other sequences are rejected, while each individual entry
may still be a ``(window, due_time)`` tuple. A bare window name
(due time None) is also accepted. The name must be a
:data:`~.const.QUEST_WINDOWS` spelling and a present due time must be
a strict 24-hour HH:MM, both validated up front so a malformed
window is rejected before any write.
"""
if type(value) is not list:
raise ValueError(
f"windows must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("windows must not be empty")
normalized: list[tuple[str, str | None]] = []
seen: set[str] = set()
for index, entry in enumerate(value):
if isinstance(entry, str):
name: object = entry
due_time: object = None
elif isinstance(entry, (tuple, list)) and len(entry) == 2:
name, due_time = entry[0], entry[1]
else:
raise ValueError(
f"windows[{index}] must be a window name or a "
f"(name, due_time) pair, got {entry!r}"
)
_validate_window(name)
if due_time is not None:
_validate_time(due_time, "due_time")
if name in seen:
raise ValueError(
f"windows must not contain duplicate window {name!r}"
)
seen.add(name)
normalized.append((name, due_time))
return normalized
def _validate_definition_id(value: object) -> int:
"""Reject non-int definition ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address definition 1 and a float would round — malformed service
input must never mutate the wrong definition.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"definition_id must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def create_quest_definition(
database: NestQuestDatabase,
title: str,
rule: ScheduleRule,
assignee_child_ids: list[int],
windows: list[str | tuple[str, str | None]],
*,
description: str | None = None,
icon: str | None = None,
) -> CreatedQuestDefinition:
"""Create a quest definition (rule + assignees + windows) atomically.
Validates the arguments up front, then persists the schedule rule,
the definition, its assignees and its windows in one transaction via
the DAO. Returns the stored definition together with its decoded
rule, assignees and windows — all read back inside that same
transaction, so the returned bundle is a consistent creation
snapshot. Raises ValueError on any rejected argument; a rejected or
failed create persists nothing.
"""
name = _validate_text(title, "title", required=True)
assert name is not None
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
assignee_ids = _validate_assignee_ids(assignee_child_ids)
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
storage = schedule_rule_to_storage(rule)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.create_with_rule_and_windows(
name,
storage,
_now_stamp(),
assignee_ids,
window_specs,
description=description_value,
icon=icon_value,
)
except ValueError as error:
# The DAO's assignee check names only the child; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(
f"assignee_child_ids: {message}"
) from error
raise
decoded_rule = schedule_rule_from_storage(storage)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def edit_quest_definition(
database: NestQuestDatabase,
definition_id: int,
*,
title: str | object = _UNSET,
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule and windows atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
(required and trimmed); ``description`` and ``icon`` are optional
and may be cleared by passing ``None`` explicitly. A provided
``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
and REPLACES the definition's schedule rule IN PLACE — the existing
``schedule_rules`` row is updated, never orphaned or duplicated. A
provided ``windows`` list REPLACES the whole window set (each entry
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
so the returned bundle is a consistent edit snapshot. Edits change
future instances only; no ``quest_instances`` or
``completion_events`` row is touched.
``today`` optionally pins the caller-resolved HA-local date the
regeneration's no-past guard and horizon are anchored to (threaded
into :func:`~.materialize.regenerate_for_definition`); it is a plain
``datetime.date`` with no HA import here — the Feature 09 service
supplies the hass-derived value. When omitted, regeneration falls
back to the host clock, same as before. ``horizon_days`` sizes the
regeneration's re-materialization window the same way (threaded into
:func:`~.materialize.regenerate_for_definition`); it defaults to
:data:`~.const.DEFAULT_HORIZON_DAYS` when omitted.
"""
_validate_definition_id(definition_id)
title_value: str | None | object = _UNSET
if title is not _UNSET:
name = _validate_text(title, "title", required=True)
assert name is not None
title_value = name
description_value: str | None | object = _UNSET
if description is not _UNSET:
description_value = _validate_text(description, "description")
icon_value: str | None | object = _UNSET
if icon is not _UNSET:
icon_value = _validate_text(icon, "icon")
rule_storage: ScheduleRuleStorage | object = _UNSET
if rule is not _UNSET:
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
rule_storage = schedule_rule_to_storage(rule)
window_specs: list[tuple[str, str | None]] | object = _UNSET
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.edit_definition(
definition_id,
title=title_value,
description=description_value,
icon=icon_value,
rule=rule_storage,
windows=window_specs,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def assign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> list[ChildRecord]:
"""Assign ``child_id`` to the definition (idempotent).
Validates that both ids are plain ints (bools and floats rejected)
and that the child exists and is active — the DAO enforces the
active/exists check inside its transaction, and also confirms the
definition exists. Assigning an already-assigned child is a no-op
(no duplicate row). Returns the definition's assignees read back
inside the DAO's same transaction, a consistent post-assignment
roster (a concurrent assign/unassign cannot race it after the
write). Raises ValueError on any rejected argument. Only the
``quest_definition_assignees`` link is written; existing instances
and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the assignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
try:
assignees = await dao.add_assignee(definition_id, child_id)
except ValueError as error:
# The DAO names only the child/definition; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(f"child_id: {message}") from error
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return assignees
async def unassign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> bool:
"""Unassign ``child_id`` from the definition; True when removed.
Validates both ids are plain ints (bools and floats rejected) and
that the definition exists, then removes the (definition, child)
link. Returns True when a row was removed and False when the child
was not currently assigned. Raises ValueError on a rejected
argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the unassignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
definition = await dao.get(definition_id)
if definition is None:
raise ValueError(
f"definition_id: quest definition {definition_id} does not exist"
)
removed = await dao.remove_assignee(definition_id, child_id)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return removed
async def set_quest_definition_active(
database: NestQuestDatabase,
definition_id: int,
is_active: bool,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Deactivate or reactivate a definition; returns the updated view.
Flipping the flag is the only removal path (Feature 06 guardrail):
a deactivated definition stops future instance generation but its
row, schedule rule, assignees and windows all survive, and existing
instances plus completion history are never touched. Reactivation
re-enables future generation; generating or removing instances is
Feature 07 and does not happen here.
``definition_id`` must be a plain int (bools and floats rejected,
since SQLite would bind ``True`` onto definition 1) and
``is_active`` must be a real bool — the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError naming the
offending field when the id is malformed, ``is_active`` is not a
bool, or the definition does not exist.
Atomicity: the existence check, the UPDATE and the readback of the
definition, its rule, assignees and windows all run inside the
connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race.
``today`` optionally pins the caller-resolved HA-local date threaded
into the activation change's regeneration (see
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
regeneration's re-materialization window the same way.
"""
_validate_definition_id(definition_id)
if not isinstance(is_active, bool):
raise ValueError(f"is_active must be a real bool, got {is_active!r}")
async with _connection_lock(database):
dao = QuestDefinitionsDao(database)
if await dao.get(definition_id) is None:
raise ValueError(
f"definition_id: quest definition {definition_id} "
"does not exist"
)
await dao.set_active(definition_id, is_active)
updated = await dao.get(definition_id)
assert updated is not None
rule_record = await ScheduleRulesDao(database).get(
updated.schedule_rule_id
)
assert rule_record is not None
assignees = await dao.list_assignees(definition_id)
windows = await dao.list_windows(definition_id)
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(rule_record)
)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return CreatedQuestDefinition(
definition=updated,
rule=rule,
assignees=assignees,
windows=windows,
)
def _bundle_snapshot(
snapshot: QuestDefinitionSnapshot,
) -> CreatedQuestDefinition:
"""Decode a DAO snapshot into the layer's rich view.
The rule record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
coherence the DAO's locked snapshot guarantees: the rule, assignees
and windows all come from one atomic read, never three separate ones.
"""
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def list_active_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return every ACTIVE definition with its decoded rule and links.
Ordered by rising definition id, matching the DAO's ``list_active``
order, so callers get a stable, repeatable sequence. Each entry is
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
the layer: the stored row plus its decoded
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
definitions and their links are read by the DAO inside one locked
transaction, so each bundle is a coherent snapshot that a concurrent
edit cannot interleave.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
) -> list[CreatedQuestDefinition]:
"""Return the definitions assigned to ``child_id`` (rich view).
``child_id`` must be a plain int (bools and floats rejected, since
SQLite would bind ``True`` onto child 1). Results are ordered by
rising definition id, matching ``list_active_definitions`` and
``list_definitions_firing_on``. Each entry is a
:class:`CreatedQuestDefinition` snapshot bundling the definition with
its decoded rule, assignees and windows, read atomically by the DAO.
"""
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_by_child(child_id)
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return sorted(bundles, key=lambda bundle: bundle.definition.id)
async def list_definitions_firing_on(
database: NestQuestDatabase,
date: str,
) -> list[CreatedQuestDefinition]:
"""Return the ACTIVE definitions whose rule fires on ``date``.
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
value raises ValueError naming the field. Each active definition's
stored rule is decoded through the task-1 storage mapping and handed
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
definitions whose decoded rule fires on the date are returned, in
rising definition-id order, each as the same rich
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
The definitions and their rules are read inside one locked
transaction, so a bundle can never filter on one rule while
presenting another state's windows.
"""
_validate_date(date, "date")
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
@@ -0,0 +1,621 @@
"""The ScheduleRule model: rule types, validation, and serialization.
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
frozen dataclass with no Home Assistant and no database imports. The
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
serializes its dict form into the ``schedule_rules`` table.
Type model: one enum member per recurrence shape. ``MONTHLY_DAY``
("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second
Tuesday of every month") are distinct types rather than one monthly
type with optional fields, so validation is total: a rule either
constructs, or raises :class:`RuleValidationError` naming the offending
field. Every type carries ONLY the fields its shape needs; carrying
any other shape's field is a construction error.
Storage mapping (Feature 02, ``schedule_rules`` table): the enum's
``storage_value`` maps model types onto the schema's CHECK values —
MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are
disambiguated by which fields are populated; CUSTOM_DAYS stores as
``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the
table stores (one element for MONTHLY_WEEKDAY, which names the
position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field
carried through from_dict/to_dict for losslessness and folded into
``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the
model-side lossless pair; the DB mapping lives in the DAO layer and is
exercised there.
Validation policy (fail fast at construction — the schema's CHECK
constraints are a second line of defense, but invalid rules must never
reach storage):
- ``interval`` >= 1 for every rule.
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans.
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is
rejected) with a weekday 0..6 for MONTHLY_WEEKDAY.
- ``month`` 1..12 is required for YEARLY.
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
when present, on or after ``start_date``.
Month-end policy (implemented in :func:`_occurs_monthly_day` and
documented for Feature 04): in each interval-eligible month, a
MONTHLY_DAY rule for a day that does not exist in that month — the
31st in April, the 30th or 29th in February, February 29th in a
non-leap year — fires on the LAST day of the month instead of being
skipped. A monthly chore still happens in every eligible month,
pinned to the month's end when its nominal date is absent; exactly one
day per eligible month matches the rule (nominal day when it exists,
month end when it does not). The interval anchors on nominal months
elapsed from start_date's month, so clamping never shifts the cadence.
"""
from __future__ import annotations
import datetime
import enum
from dataclasses import dataclass, field
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
_DATE_FORMAT = "%Y-%m-%d"
#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order).
class RuleType(enum.Enum):
"""The six recurrence shapes the engine supports.
``storage_value`` is the string written to the ``schedule_rules``
table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY
and MONTHLY_WEEKDAY are distinct shapes sharing one storage value,
disambiguated by which fields are populated.
"""
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY_DAY = "monthly_day"
MONTHLY_WEEKDAY = "monthly_weekday"
YEARLY = "yearly"
CUSTOM_DAYS = "custom_days"
@property
def storage_value(self) -> str:
"""The schema-level rule_type string this model type maps to."""
return {
RuleType.DAILY: "daily",
RuleType.WEEKLY: "weekly",
RuleType.MONTHLY_DAY: "monthly",
RuleType.MONTHLY_WEEKDAY: "monthly",
RuleType.YEARLY: "yearly",
RuleType.CUSTOM_DAYS: "custom",
}[self]
class RuleValidationError(ValueError):
"""Raised when a ScheduleRule field combination is invalid."""
def _validate_date(value: str, field_name: str) -> None:
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise RuleValidationError(
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise RuleValidationError(
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
)
def _validate_weekday_set(weekday_set) -> frozenset[int]:
"""Return a normalized frozenset or raise with a clear message.
Sequence entries are validated BEFORE set() coercion: a list like
[0, False] must not collapse the boolean into 0, and unhashable
entries must raise RuleValidationError, never a raw TypeError.
"""
if isinstance(weekday_set, (list, tuple)):
for entry in weekday_set:
# _is_plain_int: bool and int subclasses (potentially
# unhashable) are rejected before set() coercion.
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
if not isinstance(weekday_set, (set, frozenset)):
raise RuleValidationError(
f"weekday_set must be a set of weekday integers, got "
f"{type(weekday_set).__name__}"
)
if not weekday_set:
raise RuleValidationError(
"weekday_set must not be empty for weekly/custom rules"
)
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
if not 0 <= entry <= 6:
raise RuleValidationError(
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
f"got {entry!r}"
)
return frozenset(weekday_set)
def _is_plain_int(value) -> bool:
"""True only for real ints: bool excluded AND int subclasses too
(an unhashable int subclass would break the frozen-hashable model)."""
return type(value) is int
def _validate_monthly_weekday(nth_weekday, weekday) -> None:
"""Validate the MONTHLY_WEEKDAY position fields."""
if nth_weekday is None or not _is_plain_int(nth_weekday):
raise RuleValidationError(
"nth_weekday must be an integer for monthly_weekday rules, "
f"got {nth_weekday!r}"
)
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
raise RuleValidationError(
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
)
if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6:
raise RuleValidationError(
f"monthly_weekday rules require a weekday 0 (Monday)..6 "
f"(Sunday), got {weekday!r}"
)
@dataclass(frozen=True)
class ScheduleRule:
"""An immutable, validated recurrence rule (see module docstring).
Construct with keyword arguments; invalid combinations raise
:class:`RuleValidationError` at construction time. Fields map
one-to-one onto the ``schedule_rules`` table columns, with
``nth_weekday_weekday`` as the model-only weekday companion to
``nth_weekday`` (serialized inside ``weekday_set`` at the DAO
boundary).
"""
rule_type: RuleType
interval: int = 1
weekday_set: frozenset[int] | None = None
day_of_month: int | None = None
nth_weekday: int | None = None
nth_weekday_weekday: int | None = None
month: int | None = None
start_date: str = "1970-01-01"
end_date: str | None = None
def __post_init__(self) -> None:
# Normalize a string rule_type into the enum; anything else
# that is not already a RuleType is rejected.
if isinstance(self.rule_type, str):
# EXACT model names only: no whitespace stripping, no storage
# aliases. The DAO maps storage strings to model fields when
# reading rows; the model boundary stays unambiguous.
candidates = {rt.name.lower(): rt for rt in RuleType}
if self.rule_type in candidates:
object.__setattr__(
self, "rule_type", candidates[self.rule_type]
)
else:
raise RuleValidationError(
f"rule_type must be a RuleType or one of "
f"{sorted(candidates)}, got {self.rule_type!r}"
)
if not isinstance(self.rule_type, RuleType):
raise RuleValidationError(
f"rule_type must be a RuleType or one of its string "
f"values, got {self.rule_type!r}"
)
if not _is_plain_int(self.interval):
raise RuleValidationError(
f"interval must be an integer >= 1, got {self.interval!r}"
)
if self.interval < 1:
raise RuleValidationError(
f"interval must be >= 1, got {self.interval!r}"
)
_validate_date(self.start_date, "start_date")
if self.end_date is not None:
_validate_date(self.end_date, "end_date")
if self.end_date < self.start_date:
raise RuleValidationError(
f"end_date {self.end_date!r} must be on or after "
f"start_date {self.start_date!r}"
)
# Type-exclusive field policy, enforced for every type: build
# the dict of present fields, then reject every field the shape
# must not carry (weekday_set present is detectable via None vs
# set even after normalization below).
shape = self.rule_type
if shape is RuleType.DAILY:
forbidden_fields = (
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.WEEKLY:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.CUSTOM_DAYS:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.MONTHLY_DAY:
forbidden_fields = ("weekday_set", "nth_weekday",
"nth_weekday_weekday", "month")
elif shape is RuleType.MONTHLY_WEEKDAY:
forbidden_fields = ("weekday_set", "day_of_month", "month")
elif shape is RuleType.YEARLY:
forbidden_fields = (
"weekday_set",
"nth_weekday",
"nth_weekday_weekday",
)
present_forbidden = [
name
for name in forbidden_fields
if getattr(self, name) is not None
]
if present_forbidden:
raise RuleValidationError(
f"{shape.value} rules must not set "
f"{', '.join(sorted(present_forbidden))}"
)
# Required fields per shape.
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
normalized = _validate_weekday_set(self.weekday_set)
object.__setattr__(self, "weekday_set", normalized)
elif shape is RuleType.MONTHLY_DAY:
if self.day_of_month is None or not _is_plain_int(
self.day_of_month
):
raise RuleValidationError(
"monthly_day rules require day_of_month (1..31)"
)
if not 1 <= self.day_of_month <= 31:
raise RuleValidationError(
f"day_of_month must be 1..31, got {self.day_of_month!r}"
)
elif shape is RuleType.MONTHLY_WEEKDAY:
_validate_monthly_weekday(
self.nth_weekday, self.nth_weekday_weekday
)
elif shape is RuleType.YEARLY:
if self.month is None or not _is_plain_int(self.month):
raise RuleValidationError(
"yearly rules require month (1..12)"
)
if not 1 <= self.month <= 12:
raise RuleValidationError(
f"month must be 1..12, got {self.month!r}"
)
if self.day_of_month is not None and not (
_is_plain_int(self.day_of_month)
and 1 <= self.day_of_month <= 31
):
raise RuleValidationError(
f"day_of_month must be 1..31 for yearly rules, got "
f"{self.day_of_month!r}"
)
def to_dict(self) -> dict:
"""Return the lossless dict form (model-side storage shape).
``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's
weekday travels inside it (appended) plus
``nth_weekday_weekday`` so the pair round-trips without
ambiguity at the model layer.
"""
return {
"rule_type": self.rule_type.name.lower(), # model name
"interval": self.interval,
"weekday_set": (
sorted(self.weekday_set) if self.weekday_set is not None
else None
),
"day_of_month": self.day_of_month,
"nth_weekday": self.nth_weekday,
"nth_weekday_weekday": self.nth_weekday_weekday,
"month": self.month,
"start_date": self.start_date,
"end_date": self.end_date,
}
@classmethod
def from_dict(cls, data: dict) -> "ScheduleRule":
"""Rebuild a rule from :meth:`to_dict` output.
Raises :class:`RuleValidationError` on unknown keys, missing
keys, or invalid combinations — never a raw TypeError.
"""
if not isinstance(data, dict):
raise RuleValidationError(
f"rule data must be a dict, got {type(data).__name__}"
)
allowed = {
"rule_type",
"interval",
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
"start_date",
"end_date",
}
unknown = [key for key in data if key not in allowed]
if unknown:
raise RuleValidationError(
f"unknown rule fields: {sorted(unknown, key=repr)}"
)
if "rule_type" not in data:
raise RuleValidationError("missing required rule field: rule_type")
weekday_set = data.get("weekday_set")
if isinstance(weekday_set, (list, tuple)):
# Validate entries as plain ints BEFORE set() coercion, so a
# list like [0, False] cannot collapse False into 0 and an
# unhashable/int-subclass entry cannot leak a raw TypeError.
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
return cls(
rule_type=data["rule_type"],
interval=data.get("interval", 1),
weekday_set=weekday_set,
day_of_month=data.get("day_of_month"),
nth_weekday=data.get("nth_weekday"),
nth_weekday_weekday=data.get("nth_weekday_weekday"),
month=data.get("month"),
start_date=data.get("start_date", "1970-01-01"),
end_date=data.get("end_date"),
)
# ---------------------------------------------------------------------------
# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule
# ---------------------------------------------------------------------------
def _parse_date(value: str) -> datetime.date:
"""Parse a strict ISO date already validated by the model."""
return datetime.datetime.strptime(value, _DATE_FORMAT).date()
def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int:
"""Day offset from the rule's anchor date to ``target``.
The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the
first day the rule CAN fire. A negative offset means the target
precedes the rule's window entirely.
"""
return (target - _parse_date(rule.start_date)).days
def _within_window(rule: ScheduleRule, target: datetime.date) -> bool:
"""True when ``target`` is inside the rule's [start, end] window."""
if target < _parse_date(rule.start_date):
return False
if rule.end_date is not None and target > _parse_date(rule.end_date):
return False
return True
def _month_end(year: int, month: int) -> int:
"""The last calendar day of the given month (leap-aware)."""
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
"""MONTHLY_DAY evaluation with the documented month-end policy.
Policy: in each interval-eligible month, a rule for a day-of-month
that does not exist in that month (the 31st in April, the 30th in
February, the 29th of February in a non-leap year) fires on the
LAST day of that month instead of being skipped — the chore still
happens that month, pinned to the month's end when its nominal date
is absent.
The interval anchors on months ELAPSED from start_date's month: the
rule fires when the months between the anchor month and the target's
month form a multiple of the interval, keeping "every 2 months on
the 15th" aligned to the anchor month even when the firing lands on
a clamped month-end day.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def _nth_weekday_of_month(
year: int, month: int, weekday: int, nth: int
) -> int | None:
"""The day-of-month of the ``nth`` ``weekday``, or None when the
month has no such occurrence (``nth`` 5 beyond four occurrences).
Positive ``nth`` counts forward (1 = first); ``-1`` counts from the
end (last occurrence, whatever the total). Pure calendar math.
"""
if nth == -1:
last = datetime.date(year, month, _month_end(year, month))
back = (last.weekday() - weekday) % 7
return last.day - back
first = datetime.date(year, month, 1)
first_offset = (weekday - first.weekday()) % 7
day = 1 + first_offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
def _occurs_monthly_weekday(
rule: ScheduleRule, target: datetime.date
) -> bool:
"""MONTHLY_WEEKDAY evaluation.
Fires when the target is the ``nth`` occurrence of
``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A
month with fewer occurrences than ``nth`` (5 on a four-occurrence
month) does not fire — n 5 is accepted by the model but skips such
months, matching the done-condition. The interval anchors on
nominal months elapsed from start_date's month.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
if target.weekday() != rule.nth_weekday_weekday:
return False
nth_day = _nth_weekday_of_month(
target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday
)
return nth_day is not None and target.day == nth_day
def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool:
"""YEARLY evaluation with the documented leap-day clamp policy.
Policy: in each interval-eligible year, the rule fires on
``day_of_month`` of ``month`` when that day exists. A YEARLY rule
for February 29th fires on FEBRUARY 28TH in a non-leap year (the
same clamp-as-month-end policy as MONTHLY_DAY: the annual chore
still happens every year, pinned to February's end when its
nominal date is absent) rather than silently vanishing. A YEARLY
rule with NO day_of_month fires on the first day of ``month``.
The interval anchors on years elapsed from start_date's year.
"""
anchor = _parse_date(rule.start_date)
years_elapsed = target.year - anchor.year
if years_elapsed % rule.interval != 0:
return False
if target.month != rule.month:
return False
if rule.day_of_month is None:
# Day-less yearly rules fire on the first of the month.
return target.day == 1
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
"""Return True when ``rule`` fires on ``target``.
Pure function: no I/O, no timezone awareness, no mutation. Dates
are plain calendar dates (Feature 04 guardrail); the caller decides
the timezone that produced them.
"""
# Dispatch FIRST: an unimplemented shape must raise loudly even for
# dates outside the window, never silently evaluate to False.
shape = rule.rule_type
if shape not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
):
raise NotImplementedError(
f"occurs_on for {shape} lands with its own engine task "
"(Features 04 task sequence)"
)
if not _within_window(rule, target):
return False
if shape is RuleType.DAILY:
offset = (target - _parse_date(rule.start_date)).days
return offset % rule.interval == 0
if shape is RuleType.MONTHLY_DAY:
return _occurs_monthly_day(rule, target)
if shape is RuleType.MONTHLY_WEEKDAY:
return _occurs_monthly_weekday(rule, target)
if shape is RuleType.YEARLY:
return _occurs_yearly(rule, target)
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
# parity on January 1st; anchor arithmetic does not).
anchor = _parse_date(rule.start_date)
if shape is RuleType.CUSTOM_DAYS:
interval = 1
else:
interval = rule.interval
week_offset = (target - anchor).days // 7
if week_offset % interval != 0:
return False
return target.weekday() in rule.weekday_set
def occurrences_between(
rule: ScheduleRule, start: str, end: str
) -> list[str]:
"""Return every date string in [start, end] the rule fires on.
Inclusive on both ends. Raises RuleValidationError on malformed or
inverted bounds; a window entirely outside [rule.start_date,
rule.end_date] yields an empty list.
"""
if rule.rule_type not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
):
raise NotImplementedError(
f"occurrences_between for {rule.rule_type} lands with its own "
"engine task (Features 04 task sequence)"
)
_validate_date(start, "start")
_validate_date(end, "end")
start_date = _parse_date(start)
end_date = _parse_date(end)
if end_date < start_date:
raise RuleValidationError(
f"end {end!r} must be on or after start {start!r}"
)
rule_start = _parse_date(rule.start_date)
if end_date < rule_start:
return []
# Clamp the walk to the rule's own window and the queried range.
effective_start = max(start_date, rule_start)
effective_end = end_date
if rule.end_date is not None:
effective_end = min(effective_end, _parse_date(rule.end_date))
results: list[str] = []
cursor = effective_start
while cursor <= effective_end:
if occurs_on(rule, cursor):
results.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
return results
+427
View File
@@ -0,0 +1,427 @@
"""Declarative schema DDL for the NestQuest SQLite database.
Every v1 table is declared here as plain SQL statements so later schema
tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without
touching this module's tables. All statements use
``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned
migration runner is a separate later concern.
Timestamp policy: all timestamps are UTC ISO-8601 strings, set by the
caller/DAO rather than a DB default, per the feature guardrails. No
``length(created_at) = 25`` CHECK is added: the exact string shape
produced by callers is not fixed yet (``datetime.isoformat()`` may
include microseconds, changing the length), so enforcing a fixed length
here would reject valid values. Only non-null is enforced for now.
Date policy: ``start_date``/``end_date`` are ISO date strings (YYYY-MM-DD,
no time component) compared lexically by the ``end_date >= start_date``
CHECK; due_date/due_time string shapes are caller-validated by the rule
engine and DAO layers, not by DDL.
``weekday_set`` format: comma-separated weekday integers (CSV), 0 =
Monday .. 6 = Sunday, e.g. ``'0,2,4'`` for Mon/Wed/Fri. Chosen over JSON
because it sorts, compares and parses trivially in SQL. The format is
enforced at the DB level: a CHECK matches ``weekday_set`` against GLOB
patterns for element counts 1..7 of single digits 0-6 (GLOB has no
repetition quantifier, so the element count is enumerated; more than 7
elements is impossible for a weekday set). ``''``, ``'x'``, ``'7'`` and
``'0,x'`` are all rejected; NULL stays allowed for non-weekly rules.
This keeps the schema layer storage-only — the DAO layer (tasks
8c72300c+) does not need its own validator, it can rely on the CHECK.
Type affinity: SQLite applies column affinity on insert, but CHECKs
still observe text and fractional values ('abc' stays text, '1.5'
becomes REAL), so every INTEGER column carrying rule semantics carries
a ``typeof(...) = 'integer'`` guard in its CHECK.
Rule-type coherence: two CHECKs enforce that ``weekly`` carries a
non-null ``weekday_set``, ``monthly`` carries a ``day_of_month`` or an
``nth_weekday``, ``yearly`` carries a ``month``, and ``daily``/``custom``
carry none of the month-specific fields. Relaxed deliberately: the DDL
does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks"
rule or a monthly rule could later be pinned to a month) nor extra
fields on weekly/monthly/yearly rules generally, so the CHECKs only
reject what the rule engine definitively cannot express now.
Presence pattern format: ``presence_schedules.pattern`` encodes the
whole N-week repeating cycle in one TEXT column as pipe-separated
segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
week 1. An empty segment means the child is absent every day of that
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means
absent every day of every week, which is distinct from having no
schedule row at all (a child with no schedule is present every day, per
the feature guardrails). Chosen over JSON or per-week rows to stay
consistent with the CSV ``weekday_set`` convention and fully checkable
by plain CHECKs. DB-level validation is pure CHECK, no triggers or
custom functions: char-class GLOBs reject every character outside
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
(double comma, comma next to a pipe, leading/trailing comma) and
two-digit runs; a further GLOB rejects contiguous runs of 8+ comma
separated digits, which caps a segment at 7 elements — the GLOB cannot
match across a ``|``, so cross-segment boundaries stay safe; and the
segment count is pinned to ``cycle_length_weeks`` by
``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per
weekday_set this is not full CSV-set normalisation (duplicates like
``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``);
the engine treats the segment as a set. A
one-row-per-``week_index`` normalization was considered and rejected:
the flat single-column encoding keeps the done-condition's table shape
and proved fully checkable with plain CHECKs.
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
common case, and a small cap keeps anchor arithmetic and the encoded
pattern human-readable. Revisit the cap — and re-run the pattern
validation matrix in tests — if a longer cycle is ever needed.
``anchor_date`` is an ISO date string pinning the cycle: the week index
of a date is (days since anchor) // 7 mod ``cycle_length_weeks``,
computed by the presence engine (Feature 05) from anchor-date
arithmetic, never ISO week parity. Like other date columns it carries
no shape CHECK, matching the established date policy.
Presence overrides: ``presence_overrides.end_date`` is NOT NULL,
unlike ``schedule_rules.end_date`` — an override is always a concrete
date range; a single-day override stores the same date in both columns.
``is_present`` 0/1 marks the child absent/present for the whole range;
``note`` is optional free text.
Multi-assignee definitions (D-008): ``quest_definitions`` carries no
``child_id`` column. Assignment lives in
``quest_definition_assignees``, one row per (definition, child) with a
composite primary key and foreign keys to the definition and the child,
so "brush teeth" is one definition covering three children. The
migration runner rebuilds a pre-D-008 table into this shape and copies
its single-assignee column across. The composite primary key makes
duplicate assignment impossible at the storage layer.
Quest windows (D-008): ``quest_definition_windows`` declares which day
windows a definition spans — one row per (definition, window), the
composite primary key making a window idempotent per definition.
``window`` is CHECK-constrained to the three spellings ``const`` owns;
``due_time`` optionally pins a due time inside the window and is
caller-validated (strict HH:MM) by the DAO layer, matching the date
policy above. The window clock ranges live in ``const.py``, not here:
this layer stores what the caller declares and never interprets clocks.
Quest instances: ``quest_instances`` deliberately carries NO completion
status column. An instance's current state derives from the latest row
in ``completion_events`` (Feature 08), so materialization (Feature 07)
can insert instances without knowing anything about completion and the
append-only log stays the single source of truth. ``definition_id``,
``child_id``, ``due_date`` and ``window`` snapshot the
materialization-time facts ("why this instance exists"): assignment
edits and rule edits change future instances only and never rewrite
these rows. ``window`` records which declared day window produced the
instance (D-008), CHECK-constrained to the same three spellings
``const`` owns. ``due_time`` snapshots the window's optional due time
at generation for the same reason — the instance keeps its
generation-time value even if the definition is edited later.
``generated_at`` is a caller-set UTC ISO-8601 timestamp per the module
timestamp policy (no DB default, so a generation batch stamps one
coherent time).
Uniqueness: the (definition_id, child_id, due_date, window) tuple is
declared as a table-level ``UNIQUE`` constraint rather than a separate
``CREATE UNIQUE INDEX`` statement. SQLite materializes it as an
implicit unique index (``sqlite_autoindex_quest_instances_1``) — the
required "unique index prevents duplicate instances" — and the
constraint form cannot be silently dropped the way a standalone index
can, so the duplicate door stays shut. Idempotent materialization keys
on exactly this tuple: the twice-daily case (one definition, two
windows, one child, one date) yields two rows and the shared case (one
definition, three assignees, one window) yields three. No secondary
indexes beyond it are declared yet: history and board queries (Features
08/10/13) should justify their indexes as a later migration once their
query shapes exist.
No ``ON DELETE`` clauses are declared on any foreign key in this
module: children and definitions are deactivated, never deleted, so a
deletion that would orphan instances or events is a bug and must fail
loudly rather than cascade.
Completion events: ``completion_events`` is strictly append-only. That
property is enforced at the DAO layer — the completion-events DAO
exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT
by database triggers, matching the module's no-trigger policy: a RAISE
trigger would be the one exception to "pure CHECK, no triggers",
complicate the later migration runner, and still prove nothing (any
writer can run raw SQL). ``event_type`` is constrained to
``completed``/``uncompleted``; a ``missed`` row is intentionally not
representable — missed is a derived state announced on the HA event bus
(Feature 10), never an appended event.
``child_id`` is deliberately denormalized (it is also derivable via
``quest_instances``): history reporting (Feature 13) filters and rates
per child directly against the log, and the log should stay
self-describing without joins.
Actor policy: ``actor_source`` distinguishes an authenticated HA user
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
panel tap with no user context (``'panel'``, with ``actor_user_id``
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
Other actor sources are rejected; if an unattended system actor is
ever needed, that is a schema migration, not a silent widening.
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
``completed`` events — at completion time the due date is known and the
flag is decidable. For ``uncompleted`` events the flag is optional:
Feature 08 decides whether a reversal carries the reversed completion's
on-time flag or NULL, and the DDL deliberately accepts both rather than
guessing.
"""
from __future__ import annotations
from .db import NestQuestDatabase
#: GLOB patterns matching a valid ``weekday_set`` CSV: one GLOB per
#: possible element count 1..7 (a weekday set cannot exceed 7 elements).
#: Each element is a single digit 0-6, separated by a literal comma.
#: GLOB has no repetition quantifier, so the element count is enumerated.
#: ``''`` and NULL are deliberately NOT matched: NULL is allowed for
#: non-weekly rules via a separate ``IS NULL`` arm, and '' is invalid
#: because weekly rules need at least one weekday.
WEEKDAY_SET_GLOB_PATTERNS: list[str] = [
"[0-6]",
"[0-6],[0-6]",
"[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
]
SCHEMA_V1_CHILDREN_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
colour TEXT,
avatar_ref TEXT,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (typeof(sort_order) = 'integer'),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)
""",
]
SCHEMA_V1_ADMIN_USERS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS admin_users (
ha_user_id TEXT PRIMARY KEY NOT NULL,
added_at TEXT NOT NULL
)
""",
]
_WEEKDAY_SET_CHECK = " OR ".join(
f"weekday_set GLOB '{pattern}'" for pattern in WEEKDAY_SET_GLOB_PATTERNS
)
SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS schedule_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_type TEXT NOT NULL CHECK (rule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')),
interval INTEGER NOT NULL DEFAULT 1 CHECK (typeof(interval) = 'integer' AND interval >= 1),
weekday_set TEXT CHECK (weekday_set IS NULL OR ({_WEEKDAY_SET_CHECK})),
day_of_month INTEGER CHECK (day_of_month IS NULL OR (typeof(day_of_month) = 'integer' AND day_of_month >= 1 AND day_of_month <= 31)),
nth_weekday INTEGER CHECK (nth_weekday IS NULL OR (typeof(nth_weekday) = 'integer' AND nth_weekday IN (-1, 1, 2, 3, 4, 5))),
month INTEGER CHECK (month IS NULL OR (typeof(month) = 'integer' AND month >= 1 AND month <= 12)),
start_date TEXT NOT NULL,
end_date TEXT,
CHECK (end_date IS NULL OR end_date >= start_date),
CHECK (
(rule_type = 'weekly' AND weekday_set IS NOT NULL)
OR (rule_type = 'monthly' AND (day_of_month IS NOT NULL OR nth_weekday IS NOT NULL))
OR (rule_type = 'yearly' AND month IS NOT NULL)
OR (rule_type IN ('daily', 'custom') AND day_of_month IS NULL AND nth_weekday IS NULL AND month IS NULL)
)
)
""",
]
#: Column body of ``quest_definitions``, shared between the v1 DDL
#: below and the multi-assignee rebuild migration, which must recreate
#: the table in exactly this shape (D-008: one definition, many
#: assignees via ``quest_definition_assignees`` — no ``child_id``
#: column on the definition itself).
QUEST_DEFINITIONS_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)"""
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS quest_definitions {QUEST_DEFINITIONS_TABLE_SQL}
""",
]
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS quest_definition_assignees (
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
PRIMARY KEY (definition_id, child_id)
)
""",
]
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS quest_definition_windows (
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
due_time TEXT,
PRIMARY KEY (definition_id, window)
)
""",
]
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS presence_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL CHECK (
NOT pattern GLOB '*[^0-6,|]*'
AND NOT pattern GLOB '*[0-6][0-6]*'
AND NOT pattern GLOB '*,,*'
AND NOT pattern GLOB '*,|*'
AND NOT pattern GLOB '*|,*'
AND NOT pattern GLOB ',*'
AND NOT pattern GLOB '*,'
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
)
)
""",
]
SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS presence_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL REFERENCES children(id),
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)),
note TEXT,
CHECK (end_date >= start_date)
)
""",
]
#: Column body of ``quest_instances``, shared between the v1 DDL
#: below and the instance-key rebuild migration, which must recreate
#: the table in exactly this shape (D-008: the instance key is
#: (definition_id, child_id, due_date, window) — one instance per
#: assigned child per declared window per firing date).
QUEST_INSTANCES_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
due_date TEXT NOT NULL,
due_time TEXT,
generated_at TEXT NOT NULL,
UNIQUE (definition_id, child_id, due_date, window)
)"""
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL}
""",
]
#: Column body of ``completion_events``, shared between the v1 DDL
#: below and the actor-child rebuild migration, which must recreate the
#: table in exactly this shape (D-008: ``actor_child_id`` records which
#: panel profile was tapped — set for panel events, NULL for admin
#: ones; legacy panel rows backfill to ``child_id``, the child they
#: always were).
COMPLETION_EVENTS_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
child_id INTEGER NOT NULL REFERENCES children(id),
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
actor_user_id TEXT,
actor_child_id INTEGER REFERENCES children(id),
occurred_at TEXT NOT NULL,
was_on_time INTEGER CHECK (
was_on_time IS NULL
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
),
CHECK (
(actor_source = 'user' AND actor_user_id IS NOT NULL)
OR (actor_source = 'panel' AND actor_user_id IS NULL)
),
CHECK (
(actor_source = 'user' AND actor_child_id IS NULL)
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
),
CHECK (
(event_type = 'completed' AND was_on_time IS NOT NULL)
OR (event_type = 'uncompleted')
)
)"""
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
""",
]
#: Generic integration-owned key/value state (schema version 7,
#: Feature 11): persistent bookkeeping that belongs to the integration
#: rather than any domain table — currently the missed-sweep
#: watermark. ``value`` is a plain string; callers interpret it.
META_STATE_TABLE_SQL = """(
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL
)"""
SCHEMA_V7_META_STATE_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL}
""",
]
SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL,
*SCHEMA_V1_SCHEDULE_RULES_DDL,
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
*SCHEMA_V7_META_STATE_DDL,
]
async def async_apply_ddl(
database: NestQuestDatabase, statements: list[str]
) -> None:
"""Apply each DDL statement inside one transaction on ``database``.
Idempotent: every statement is ``CREATE TABLE IF NOT EXISTS``, so
applying the same list twice leaves the schema and its rows intact.
"""
async with database.transaction():
for sql in statements:
await database.execute(sql)
@@ -0,0 +1,46 @@
"""Explicit admin-only versus open service registry (Feature 09).
One mapping, one file: every registered ``nestquest`` service name is
either ``open`` or ``admin_only``. The permission decorator (later
Feature 09 tasks) reads this table; it does not take per-handler
policy arguments.
"""
from __future__ import annotations
from .const import (
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,
SERVICE_UPDATE_QUEST_DEFINITION,
)
OPEN = "open"
ADMIN_ONLY = "admin_only"
SERVICE_POLICY: dict[str, str] = {
SERVICE_COMPLETE_QUEST: OPEN,
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
SERVICE_REGENERATE: ADMIN_ONLY,
}
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
raise RuntimeError(
"SERVICE_POLICY must name exactly the registered nestquest services, "
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
)
@@ -0,0 +1,379 @@
"""Explicit, Home-Assistant-free settings object for NestQuest core.
The integration reads its configuration off a Home Assistant config
entry (``entry.options``); the core package must not. This module
bridges that split: it defines a frozen, validation-carrying
:class:`NestQuestSettings` dataclass that the integration constructs
from ``entry.options`` (via :meth:`NestQuestSettings.from_options`, or
:meth:`NestQuestSettings.from_options_resilient` for the per-field
fallback used at setup) and then passes into the core paths that
previously read HA config themselves — the rolling horizon
(``horizon_days``) and the daily rollover time (``day_rollover_time``),
plus the Feature 11 notification settings (notify target, the three
summary/reminder/report times, and the four independent enable toggles).
Nothing in this module imports ``homeassistant`` or touches a config
entry; ``from_options`` accepts any plain mapping keyed by the
:data:`~.const.CONF_*` names. The validation semantics match the
options flow's: ``horizon_days`` must be a real integer >= 1 (bool
rejected), and ``day_rollover_time`` plus the three notification times
must be strict ``HH:MM`` 24-hour strings. Construction therefore can
never yield a settings object whose horizon could shrink or explode the
materialized window, or whose rollover fires at an unparsable time.
The strict resolvers (:func:`_resolve_horizon_days`, :func:`_validate_hhmm`,
:func:`_resolve_bool`, :func:`_resolve_optional_str`) reject ``None``; the
``None``-as-absent mapping lives ONLY in :meth:`from_options` /
:meth:`from_options_resilient` (via :func:`_option_value`), so a hand-built
:class:`NestQuestSettings` cannot smuggle a ``None`` past
:meth:`__post_init__`.
"""
from __future__ import annotations
import datetime
from collections.abc import Callable
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Mapping
from .const import (
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_AFTERNOON_REMINDER_TIME,
CONF_CELEBRATION_ENABLED,
CONF_DAY_ROLLOVER_TIME,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_END_OF_DAY_REPORT_TIME,
CONF_HORIZON_DAYS,
CONF_MORNING_SUMMARY_ENABLED,
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME,
LOGGER,
TIME_PATTERN,
)
def _option_value(
opts: Mapping[str, Any], key: str, default: Any
) -> Any:
"""Return ``opts[key]`` when present and non-None, else ``default``.
This is the SOLE place the ``None``-as-absent mapping lives: a stored
``None`` is treated the same as an absent key — the historical
``horizon_days`` path mapped a missing-or-None value to the default,
and this keeps that forgiving semantics uniform across every field.
The per-field resolvers (:func:`_resolve_horizon_days`,
:func:`_validate_hhmm`, :func:`_resolve_bool`,
:func:`_resolve_optional_str`) are STRICT — they reject ``None`` —
so a partially-populated ``entry.options`` dict (or one migrated
across versions) never raises just because a key carries ``None``,
and a hand-built :class:`NestQuestSettings` cannot smuggle a
``None`` past :meth:`NestQuestSettings.__post_init__`.
"""
if key not in opts:
return default
value = opts[key]
return default if value is None else value
def _validate_hhmm(value: Any, *, field: str) -> str:
"""Validate a strict ``HH:MM`` string and return it; raise naming ``field``.
Uses the shared :data:`~.const.TIME_PATTERN` (the same compiled
regex the options flow applies), so a stored option and a freshly
validated form submit parse the same way. ``None`` (and any
non-string) is rejected; the ``None``-as-absent mapping lives ONLY
in :meth:`NestQuestSettings.from_options` (via :func:`_option_value`).
"""
if not isinstance(value, str) or TIME_PATTERN.fullmatch(value) is None:
raise ValueError(
f"{field} must be a strict HH:MM string, got {value!r}"
)
return value
def _resolve_horizon_days(value: Any) -> int:
"""Validate a horizon value and return the resolved day count.
A bool is rejected (SQLite binds ``True``/``False`` onto an int), a
non-int is rejected, and a sub-1 value is rejected — so a malformed
option can never shrink or explode the materialized horizon.
``None`` is rejected here too; the ``None``-as-absent mapping lives
ONLY in :meth:`NestQuestSettings.from_options` (via
:func:`_option_value`), so a hand-built
:class:`NestQuestSettings(horizon_days=None)` raises in
:meth:`NestQuestSettings.__post_init__` rather than silently
constructing an object whose :meth:`NestQuestSettings.horizon_window`
would later raise ``TypeError`` on ``timedelta(days=None)``.
"""
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ValueError(
f"horizon_days must be a positive integer, got {value!r}"
)
return value
def _resolve_bool(value: Any) -> bool:
"""Return a real bool; a non-bool (including ``None``) is rejected.
voluptuous ``bool`` would coerce strings, so the options flow uses a
strict-bool validator and this mirrors it. The ``None``-as-absent
mapping lives ONLY in :meth:`NestQuestSettings.from_options` (via
:func:`_option_value`).
"""
if not isinstance(value, bool):
raise ValueError(f"expected a boolean, got {value!r}")
return value
def _resolve_optional_str(value: Any) -> str:
"""Return a stripped notify target string; a non-str is rejected.
A padded or whitespace-only value is STRIPPED to its trim — the
shipped default is the empty string, meaning "no notifications are
configured yet". This is intentionally MORE LENIENT than the options
flow, which REJECTS a target that differs from its own trim (so a
padded value never persists): the integration tolerates a stored
padded value (e.g. one migrated from an older schema) by normalizing
it rather than crashing setup. The ``None``-as-absent mapping lives
ONLY in :meth:`NestQuestSettings.from_options` (via
:func:`_option_value`). :meth:`NestQuestSettings.__post_init__`
applies this SAME normalizer so a hand-built
``NestQuestSettings(notify_target=' x ')`` is stripped to ``'x'``
exactly like the options-flow path (no padded value can slip through
direct construction either).
"""
if not isinstance(value, str):
raise ValueError(f"notify_target must be a string, got {value!r}")
return value.strip()
#: The single field table the two builders share. Each row is
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
#: applies every validator (raising on the first invalid value) while
#: ``from_options_resilient`` wraps each in its own try/except so a single
#: bad sibling falls back to its default alone. Driving both builders
#: from this table avoids three-way drift between ``__post_init__``'s
#: inline checks and the two option builders' field lists: the only
#: places that enumerate the fields are this table and
#: ``__post_init__`` (which validates a hand-built instance without the
#: None->default mapping, so it stays inline).
_FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
("horizon_days", CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, _resolve_horizon_days),
(
"day_rollover_time",
CONF_DAY_ROLLOVER_TIME,
DEFAULT_DAY_ROLLOVER_TIME,
lambda v: _validate_hhmm(v, field="day_rollover_time"),
),
("notify_target", CONF_NOTIFY_TARGET, "", _resolve_optional_str),
(
"morning_summary_time",
CONF_MORNING_SUMMARY_TIME,
DEFAULT_MORNING_SUMMARY_TIME,
lambda v: _validate_hhmm(v, field="morning_summary_time"),
),
(
"afternoon_reminder_time",
CONF_AFTERNOON_REMINDER_TIME,
DEFAULT_AFTERNOON_REMINDER_TIME,
lambda v: _validate_hhmm(v, field="afternoon_reminder_time"),
),
(
"end_of_day_report_time",
CONF_END_OF_DAY_REPORT_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
lambda v: _validate_hhmm(v, field="end_of_day_report_time"),
),
(
"morning_summary_enabled",
CONF_MORNING_SUMMARY_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
),
(
"afternoon_reminder_enabled",
CONF_AFTERNOON_REMINDER_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
),
(
"end_of_day_report_enabled",
CONF_END_OF_DAY_REPORT_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
),
(
"celebration_enabled",
CONF_CELEBRATION_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
),
)
@dataclass(frozen=True)
class NestQuestSettings:
"""Explicit, HA-free configuration carried into core paths.
The integration builds this once from ``entry.options`` (via
:meth:`from_options`, or :meth:`from_options_resilient` for the
per-field fallback used at setup) and threads it into the horizon
materialization and the daily-rollover scheduling. Core never
reads HA config itself; callers pass the resolved horizon and
rollover time in.
All fields are validated at construction; the dataclass is frozen so
a stale settings object cannot be mutated in place after the
integration hands it off.
"""
horizon_days: int = DEFAULT_HORIZON_DAYS
day_rollover_time: str = DEFAULT_DAY_ROLLOVER_TIME
notify_target: str = ""
morning_summary_time: str = DEFAULT_MORNING_SUMMARY_TIME
afternoon_reminder_time: str = DEFAULT_AFTERNOON_REMINDER_TIME
end_of_day_report_time: str = DEFAULT_END_OF_DAY_REPORT_TIME
morning_summary_enabled: bool = DEFAULT_AUTOMATION_ENABLED
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
def __post_init__(self) -> None:
# Validate STRICTLY at construction so a hand-built instance
# cannot carry an invalid value: the same rules from_options
# applies, but WITHOUT the None->default mapping (that lives
# only in from_options via _option_value). A None horizon, time,
# or toggle raises here rather than later blowing up
# horizon_window or the rollover listener.
_resolve_horizon_days(self.horizon_days)
_validate_hhmm(self.day_rollover_time, field="day_rollover_time")
# ``notify_target`` is normalised the SAME way as from_options
# (via _resolve_optional_str): a non-str is rejected and a
# padded/whitespace-only value is STRIPPED to its trim. This
# keeps a hand-built ``NestQuestSettings(notify_target=' x ')``
# behaving like the options flow (which strips on persist) so a
# padded value cannot slip through direct construction either.
# The dataclass is frozen, so the stripped value is written via
# object.__setattr__.
stripped_notify_target = _resolve_optional_str(self.notify_target)
object.__setattr__(self, "notify_target", stripped_notify_target)
_validate_hhmm(self.morning_summary_time, field="morning_summary_time")
_validate_hhmm(
self.afternoon_reminder_time, field="afternoon_reminder_time"
)
_validate_hhmm(
self.end_of_day_report_time, field="end_of_day_report_time"
)
for name in (
"morning_summary_enabled",
"afternoon_reminder_enabled",
"end_of_day_report_enabled",
"celebration_enabled",
):
value = getattr(self, name)
if not isinstance(value, bool):
raise ValueError(
f"{name} must be a boolean, got {value!r}"
)
@property
def day_rollover_hour_minute(self) -> tuple[int, int]:
"""The (hour, minute) the daily rollover fires at."""
# The string is validated at construction, so a plain partition
# is safe and avoids re-running the regex on every access.
hour_str, _, minute_str = self.day_rollover_time.partition(":")
return int(hour_str), int(minute_str)
def horizon_window(
self, today: datetime.date
) -> tuple[datetime.date, datetime.date]:
"""Return the rolling horizon window ``[today, today + horizon_days]``.
``today`` is REQUIRED (there is no host-clock default): the
integration passes the HA-local ``hass.config.time_zone`` date so
the horizon tracks the household's own day, and a silent
host-clock fallback would be a wrong-day footgun (a test or a
caller that forgets to pass ``today`` would otherwise materialize
against the machine's calendar, not the household's). The
returned window is closed/inclusive — the same shape
:func:`~.materialize.materialize` walks.
"""
end = today + datetime.timedelta(days=self.horizon_days)
return today, end
@classmethod
def from_options(
cls, options: Mapping[str, Any] | None = None
) -> "NestQuestSettings":
"""Build settings from a CONF_*-keyed mapping (e.g. ``entry.options``).
Reads the same keys the options flow persists, applying the
matching defaults when a key is absent OR ``None`` (via
:func:`_option_value`, the SOLE place the ``None``-as-absent
mapping lives) and validating STRICTLY: ``horizon_days`` is a
positive int (bool rejected), the four times are strict
``HH:MM``, the four toggles are real booleans, and
``notify_target`` is a string (stripped). A malformed value
RAISES — the integration uses :meth:`from_options_resilient` at
setup to fall back per field instead of crashing. A ``None``
mapping yields the all-defaults settings — useful for tests and
for a fresh install whose options dict is empty.
``options`` is consumed read-only via
:class:`types.MappingProxyType`, so this method never mutates
the caller's mapping.
"""
opts: Mapping[str, Any] = (
MappingProxyType({}) if options is None else MappingProxyType(options)
)
return cls(
**{
field: validator(_option_value(opts, key, default))
for field, key, default, validator in _FIELDS
}
)
@classmethod
def from_options_resilient(
cls, options: Mapping[str, Any] | None = None
) -> "NestQuestSettings":
"""Build settings, falling back PER FIELD on a malformed value.
Unlike :meth:`from_options` (which raises on the first invalid
field), this resolves EACH field in its own try/except: a
malformed value logs a :data:`~.const.LOGGER` warning naming the
field and falls back to that field's default. The integration
uses this at setup so a single bad sibling option (e.g. a
migrated ``morning_summary_time`` of ``'25:00'``) cannot
silently reset the user's ``horizon_days`` or
``day_rollover_time`` — the two fields core actually consumes.
An absent or ``None`` key maps to the default via
:func:`_option_value` WITHOUT a warning (that is the normal
missing-key path); only a present, non-``None``, invalid value
triggers a warning and a per-field fallback.
"""
opts: Mapping[str, Any] = (
MappingProxyType({}) if options is None else MappingProxyType(options)
)
resolved: dict[str, Any] = {}
for field, key, default, validator in _FIELDS:
raw = _option_value(opts, key, default)
try:
resolved[field] = validator(raw)
except ValueError as err:
LOGGER.warning(
"NestQuest option %s is invalid (%s); falling back "
"to the default %r",
field,
err,
default,
)
resolved[field] = default
return cls(**resolved)
+30
View File
@@ -0,0 +1,30 @@
"""SQLite storage path resolution for the NestQuest storage layer.
Home-Assistant-free: callers resolve the database file location from
their own config (HA resolves it from ``hass.config.path``) and pass
the full path here; this module ensures the parent directory exists
off-thread via :func:`asyncio.to_thread` and returns the absolute path.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
def _ensure_dir(path: Path) -> None:
"""Create the given directory and any missing parents."""
path.mkdir(parents=True, exist_ok=True)
async def async_get_db_path(db_path: Path | str) -> Path:
"""Return the absolute path to the NestQuest SQLite database file.
``db_path`` is the full path to the database file (the integration
resolves it from ``hass.config.path(SQLITE_DB_FILENAME)``); the
parent directory is created off the event loop via
:func:`asyncio.to_thread` so this module never blocks and never
imports Home Assistant.
"""
path = Path(db_path).absolute()
await asyncio.to_thread(_ensure_dir, path.parent)
return path
+8 -315
View File
@@ -1,319 +1,12 @@
"""Typed DAO layer for the children and admin_users tables.
"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package.
All SQL for ``children`` and ``admin_users`` lives in this module per
the feature guardrail: callers get typed dataclasses back and never see
raw rows or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Error contract: violations surface as the underlying
:class:`sqlite3.IntegrityError` (duplicate, foreign-key, or CHECK
failure) from the execute call; the DAO adds no wrapping exception
type. A get that matches nothing returns ``None``; a mutation that
matches nothing is reported by :class:`~.db.ExecutionResult.rowcount`,
which each method returns where the caller may need it (reorder, and
the admin remove/set paths).
The children/admin DAO lives in
:mod:`custom_components.nestquest.core.dao_children` (extracted in
task d0b691d8); this module re-exports it so existing
``from .dao_children import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from .db import NestQuestDatabase
@dataclass(frozen=True)
class ChildRecord:
"""One row of ``children``."""
id: int
display_name: str
colour: str | None
avatar_ref: str | None
sort_order: int
is_active: bool
created_at: str
@dataclass(frozen=True)
class AdminUserRecord:
"""One row of ``admin_users``."""
ha_user_id: str
added_at: str
def _child_from_row(row: tuple) -> ChildRecord:
"""Build a :class:`ChildRecord` from a SELECT row."""
return ChildRecord(
id=row[0],
display_name=row[1],
colour=row[2],
avatar_ref=row[3],
sort_order=row[4],
is_active=bool(row[5]),
created_at=row[6],
)
_CHILD_COLUMNS = (
"id, display_name, colour, avatar_ref, sort_order, is_active, created_at"
)
#: One asyncio.Lock per (connection wrapper, running loop), shared by
#: every ChildrenDao instance over the same database: the wrapper
#: rejects a second concurrent transaction with RuntimeError, so ALL
#: transactional DAO operations on one connection must queue here, not
#: per-instance. Keyed like the migration runner's lock; entries are
#: never evicted (a config entry holds one wrapper per process).
_CONNECTION_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
def _connection_lock(database) -> asyncio.Lock:
"""Return the shared transaction-serializing lock for ``database``."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _CONNECTION_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_CONNECTION_LOCKS[key] = lock
return lock
class ChildrenDao:
"""Typed async data access for the ``children`` table.
Deletion is deliberately absent: children are deactivated, never
deleted, because completion history references them (feature
guardrail).
"""
def __init__(self, database) -> None:
self._database = database
async def create(
self,
display_name: str,
created_at: str,
*,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int = 0,
is_active: bool = True,
) -> ChildRecord:
"""Insert one child and return the record as stored."""
result = await self._database.execute(
"INSERT INTO children (display_name, colour, avatar_ref, "
"sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(
display_name,
colour,
avatar_ref,
sort_order,
int(is_active),
created_at,
),
)
child = await self.get(result.lastrowid)
assert child is not None
return child
async def get(self, child_id: int) -> ChildRecord | None:
"""Return the child with ``child_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_CHILD_COLUMNS} FROM children WHERE id = ?",
(child_id,),
)
return _child_from_row(row) if row is not None else None
async def list_active(self) -> list[ChildRecord]:
"""Return active children ordered by sort_order, then id."""
rows = await self._database.fetch_all(
f"SELECT {_CHILD_COLUMNS} FROM children WHERE is_active = 1 "
"ORDER BY sort_order, id"
)
return [_child_from_row(row) for row in rows]
async def list_all(self) -> list[ChildRecord]:
"""Return all children ordered by sort_order, then id."""
rows = await self._database.fetch_all(
f"SELECT {_CHILD_COLUMNS} FROM children "
"ORDER BY sort_order, id"
)
return [record for record in map(_child_from_row, rows)]
async def update(
self,
child_id: int,
*,
display_name: str | None = None,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int | None = None,
) -> int:
"""Update the given fields; returns rows updated (0 if absent).
Only non-None arguments are written, so a caller cannot
accidentally blank a column by omitting it.
"""
assignments: list[str] = []
parameters: list[object] = []
if display_name is not None:
assignments.append("display_name = ?")
parameters.append(display_name)
if colour is not None:
assignments.append("colour = ?")
parameters.append(colour)
if avatar_ref is not None:
assignments.append("avatar_ref = ?")
parameters.append(avatar_ref)
if sort_order is not None:
assignments.append("sort_order = ?")
parameters.append(sort_order)
if not assignments:
return 0
parameters.append(child_id)
result = await self._database.execute(
f"UPDATE children SET {', '.join(assignments)} WHERE id = ?",
tuple(parameters),
)
return result.rowcount
async def set_active(self, child_id: int, is_active: bool) -> int:
"""Set the child's active flag; returns rows updated (0 if absent)."""
result = await self._database.execute(
"UPDATE children SET is_active = ? WHERE id = ?",
(int(is_active), child_id),
)
return result.rowcount
async def reorder(self, ordered_ids: list[int]) -> None:
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
The list must be a COMPLETE permutation of the children table:
every existing child id appears exactly once. Partial lists
(missing an existing child) and unknown ids (naming a child
that does not exist) are rejected with ValueError BEFORE any
write, leaving the current order untouched — a silent partial
reorder would leave unlisted children with stale positions and
hide a caller bug. Duplicate ids are rejected up front: they
cannot all hold distinct positions, and silently assigning the
last occurrence's index would hide a caller bug.
Runs inside one transaction; positions are staged at offset
-len (all negative, so any permutation of the same ids never
collides mid-reorder under the wrapper's single-connection
serialization), then finalized 0..n-1.
Concurrent reorder() calls on one connection are serialized by
a connection-scoped asyncio lock (shared across every ChildrenDao
over the same wrapper, however many instances): the wrapper's
transaction() rejects a second concurrent transaction with
RuntimeError, so racing callers would crash instead of
queueing. The lock makes the second call wait, then run
against the first call's final state — which also keeps the
completeness check honest: the re-read of the table happens
inside the same lock and transaction as the writes.
"""
if len(set(ordered_ids)) != len(ordered_ids):
raise ValueError(
"reorder() received duplicate child ids; every id must "
"appear at most once"
)
async with _connection_lock(self._database):
async with self._database.transaction():
rows = await self._database.fetch_all(
f"SELECT id FROM children"
)
existing = {row[0] for row in rows}
listed = set(ordered_ids)
missing = sorted(existing - listed)
unknown = sorted(listed - existing)
if missing or unknown:
details = []
if missing:
details.append(
f"missing child ids {missing} (a partial "
"reorder would leave them with stale "
"positions)"
)
if unknown:
details.append(
f"unknown child ids {unknown}"
)
raise ValueError(
"reorder() must list every child exactly once; "
+ "; ".join(details)
)
offset = len(ordered_ids)
await self._database.execute_many(
"UPDATE children SET sort_order = ? WHERE id = ?",
[
(-(offset + index), child_id)
for index, child_id in enumerate(ordered_ids)
],
)
await self._database.execute_many(
"UPDATE children SET sort_order = ? WHERE id = ?",
[
(index, child_id)
for index, child_id in enumerate(ordered_ids)
],
)
class AdminUsersDao:
"""Typed async access to the ``admin_users`` allowlist.
The allowlist stores HA user IDs, never usernames (feature
guardrail); an empty allowlist means no admin, enforced by callers
resolving through :meth:`exists`.
"""
def __init__(self, database) -> None:
self._database = database
async def add(self, ha_user_id: str, added_at: str) -> bool:
"""Add a user to the allowlist; True if inserted, False if present.
Idempotent under concurrency: a single INSERT ... ON CONFLICT DO
NOTHING decides and inserts in one statement (the wrapper
serializes statements, not check-then-insert sequences, so a
SELECT/INSERT pair would let two concurrent adds both see no
row and the second one raise IntegrityError). rowcount reports
whether the insert actually happened.
"""
result = await self._database.execute(
"INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?) "
"ON CONFLICT (ha_user_id) DO NOTHING",
(ha_user_id, added_at),
)
return result.rowcount > 0
async def remove(self, ha_user_id: str) -> bool:
"""Remove a user from the allowlist; True if a row was removed."""
result = await self._database.execute(
"DELETE FROM admin_users WHERE ha_user_id = ?",
(ha_user_id,),
)
return result.rowcount > 0
async def list(self) -> list[AdminUserRecord]:
"""Return every allowlisted user, oldest first."""
rows = await self._database.fetch_all(
"SELECT ha_user_id, added_at FROM admin_users "
"ORDER BY added_at, ha_user_id"
)
return [
AdminUserRecord(ha_user_id=row[0], added_at=row[1])
for row in rows
]
async def exists(self, ha_user_id: str) -> bool:
"""Return True if ``ha_user_id`` is allowlisted."""
row = await self._database.fetch_one(
"SELECT 1 FROM admin_users WHERE ha_user_id = ?",
(ha_user_id,),
)
return row is not None
from .core.dao_children import * # noqa: F401,F403
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
+8 -828
View File
@@ -1,832 +1,12 @@
"""Typed DAO layer for quest_instances and the append-only completion_events.
"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Append-only policy (Feature 08, D-005): ``completion_events`` is
strictly append-only. This module is where that property is enforced
mechanically — the completion-events DAO below exposes NO update or
delete method at all; completions append, un-completions append a
separate reversal event, and instance state derives from the latest
event. A package-wide guard test (bottom of the test module) keeps
mutation SQL for this table out of every other module.
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
(definition_id, child_id, due_date, window) — re-running
materialization for a tuple whose instance already exists refreshes
only the generation-time snapshot columns and never duplicates a row.
The schema's UNIQUE(definition_id, child_id, due_date, window) is the
last line of defense. The upsert refuses a child that is not an
assignee of the definition and a window outside ``const.QUEST_WINDOWS``
(D-008 multi-assignee, multi-window model).
``delete_future_uncompleted`` implements the Feature 06/07 rule that
reassignment and schedule edits regenerate future instances: it
removes only instances at or after the cutoff that have NO completion
event, inside one transaction, so an instance completed mid-call can
never be deleted. Instances in the past are never touched (Feature 07
guardrail: never delete history), and no method here deletes a single
instance that has a completion event.
The instances/completion-events DAO lives in
:mod:`custom_components.nestquest.core.dao_instances`
(extracted in task d0b691d8); this module re-exports it so existing
``from .dao_instances import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import _connection_lock
from .dao_rules import _validate_date, _validate_window
from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
#: One accepted shape bounds lexicographic range queries correctly, so
#: the occurred-at scope does not guess at timestamp spellings.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _today() -> datetime.date:
"""Return the current local calendar date (the single date source).
Every "today" read in this module goes through this one function so
the delete clamp and the materialize walk's no-past guard read the
same clock, and a caller or test can pin/roll that clock
deterministically (e.g. a queued-across-midnight regression). Pure
clock read — no business rule lives here.
"""
return datetime.date.today()
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the caller-resolved "today", or the host date when absent.
The no-past guard and the delete cutoffs must compare due dates
against the SAME calendar date the materialization horizon was built
from. Home Assistant's configured time zone can lag the host clock
around midnight, so when the caller has already resolved the local
date (``today``) that anchor is authoritative; ``None`` falls back to
the host clock (:func:`_today`), which is exactly the previous
behavior for callers with no HA-local date.
"""
return _today() if today is None else today
def _reject_past(due_date: str, today_iso: str) -> None:
"""Raise unless ``due_date`` is on or after ``today_iso``.
The single no-past rule shared by :meth:`QuestInstancesDao.upsert`
and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never
generated in the past, measured against the resolved "today".
"""
if due_date < today_iso:
raise ValueError(
f"instances are never generated in the past: due_date "
f"{due_date!r} is before today {today_iso!r}"
)
def _validate_utc_timestamp(value: str, field: str) -> None:
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
Same strictness policy as _validate_date: parse with the exact
format, then round-trip the parsed value against the input so
non-padded or re-ordered shapes are rejected. The round-trip
strips the UTC offset (isoformat() omits it when the offset is
zero), so the suffix is appended before comparing.
"""
try:
parsed = datetime.datetime.strptime(
value, _UTC_TIMESTAMP_FORMAT
)
except (TypeError, ValueError):
raise ValueError(
f"{field} must be a UTC ISO-8601 timestamp "
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
) from None
if parsed.isoformat() + "+00:00" != value:
raise ValueError(
f"{field} must be a strict UTC timestamp "
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
)
def _next_day_start(date_str: str) -> str:
"""Return the UTC timestamp one second past ``date_str``'s last second.
Gives range queries an exact half-open upper bound without
fractional-second guessing.
"""
day = datetime.date.fromisoformat(date_str)
next_day = day + datetime.timedelta(days=1)
return f"{next_day.isoformat()}T00:00:00+00:00"
@dataclass(frozen=True)
class QuestInstanceRecord:
"""One row of ``quest_instances``."""
id: int
definition_id: int
child_id: int
window: str
due_date: str
due_time: str | None
generated_at: str
@dataclass(frozen=True)
class CompletionEventRecord:
"""One row of ``completion_events`` (append-only table)."""
id: int
instance_id: int
child_id: int
event_type: str
actor_source: str
actor_user_id: str | None
actor_child_id: int | None
occurred_at: str
was_on_time: bool | None
_INSTANCE_COLUMNS = (
"id, definition_id, child_id, window, due_date, due_time, generated_at"
)
_EVENT_COLUMNS = (
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
"actor_child_id, occurred_at, was_on_time"
)
#: The only event types the append path accepts; the schema CHECK is
#: the backstop, this is the DAO's typed front door.
EVENT_COMPLETED = "completed"
EVENT_UNCOMPLETED = "uncompleted"
def _instance_from_row(row: tuple) -> QuestInstanceRecord:
return QuestInstanceRecord(
id=row[0],
definition_id=row[1],
child_id=row[2],
window=row[3],
due_date=row[4],
due_time=row[5],
generated_at=row[6],
)
def _event_from_row(row: tuple) -> CompletionEventRecord:
return CompletionEventRecord(
id=row[0],
instance_id=row[1],
child_id=row[2],
event_type=row[3],
actor_source=row[4],
actor_user_id=row[5],
actor_child_id=row[6],
occurred_at=row[7],
was_on_time=None if row[8] is None else bool(row[8]),
)
class QuestInstancesDao:
"""Typed async access to the ``quest_instances`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert(
self,
definition_id: int,
child_id: int,
due_date: str,
generated_at: str,
*,
window: str,
due_time: str | None = None,
today: datetime.date | None = None,
) -> QuestInstanceRecord:
"""Create the dated instance for the widened key idempotently.
The instance key is (definition_id, child_id, due_date, window)
(D-008): running materialization twice for the same tuple
yields ONE row — the ON CONFLICT path refreshes the
generation-time snapshot columns (due_time) and regenerates the
stamp, but can never create a duplicate. The schema's
UNIQUE(definition_id, child_id, due_date, window) backs this
up, so the twice-daily case (two windows, one child, one date)
yields two rows and the shared case (three assignees, one
window) yields three.
Guardrails enforced here:
- ``due_date`` must be TODAY or later: instances are never
generated in the past (Feature 07). The caller's
materialization horizon is future-dated by definition.
``today`` optionally pins the resolved HA-local date the
guard compares against (falling back to the host clock when
omitted) so a household time zone behind the host around
midnight is not misread as "past".
- ``window`` must be one of ``const.QUEST_WINDOWS``.
- An existing instance WITH a completion event is IMMUTABLE:
the conflict path must not rewrite its snapshot columns
(Feature 07/08). The upsert refuses with ValueError in that
case — silently no-oping would mask a materialization bug.
- The child must be an assignee of the definition (D-008
multi-assignee model).
The definition must exist (validated atomically under the
connection lock so a concurrent definition deactivation or
child deletion cannot slip between check and insert).
"""
_validate_date(due_date, "due_date")
_validate_window(window)
# Fail fast on obviously-past dates BEFORE queuing on the
# connection lock (a nice early error); the AUTHORITATIVE
# check runs after the lock is acquired, because a caller can
# wait on the lock across midnight — a date that was "today"
# at call time may be "yesterday" by the time the INSERT runs.
# A caller-supplied ``today`` (HA-local) is used for BOTH
# checks and never re-read, since it is already the anchor the
# batch runs against.
today_date = _resolve_today(today)
_reject_past(due_date, today_date.isoformat())
async with _connection_lock(self._database):
async with self._database.transaction():
# Re-read "today" under the lock: this is the date the
# insert actually executes on, so the no-past rule
# holds across midnight rollovers. A pinned HA-local
# anchor is exempt from the re-read (see above).
if today is None:
today_date = _today()
_reject_past(due_date, today_date.isoformat())
definition = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions WHERE id = ?",
(definition_id,),
)
if definition is None:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
assignee = await self._database.fetch_one(
"SELECT 1 FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
if assignee is None:
raise ValueError(
f"quest definition {definition_id} is not "
f"assigned to child {child_id}; instance must "
"carry an assigned child"
)
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? "
"AND due_date = ? AND window = ?)",
(definition_id, child_id, due_date, window),
)
if existing is not None:
raise ValueError(
f"instance for (definition {definition_id}, "
f"child {child_id}, due_date {due_date!r}, "
f"window {window!r}) already has a completion "
"event and is immutable; it cannot be regenerated"
)
await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, child_id, due_date, "
"window) DO UPDATE SET "
"due_time = excluded.due_time, "
"generated_at = excluded.generated_at",
(
definition_id,
child_id,
window,
due_date,
due_time,
generated_at,
),
)
instance = await self.get(
definition_id, child_id, due_date, window
)
assert instance is not None
return instance
async def upsert_if_valid(
self,
definition_id: int,
child_id: int,
due_date: str,
generated_at: str,
*,
window: str,
due_time: str | None = None,
today: datetime.date | None = None,
) -> QuestInstanceRecord | None:
"""Insert one dated instance iff its tuple is still materializable.
This is the materialization walk's single write path. Unlike the
generic :meth:`upsert`, which validates only existence and
assignment, this method re-validates EVERY materialization
precondition in the SAME connection-lock-held transaction as the
INSERT — closing the check-then-insert gap a concurrent config
write could otherwise slip through:
- the definition exists AND is active (a definition deactivated
after the input snapshot is skipped, never written);
- the child exists AND is active (a deactivated child is skipped);
- the (definition, child) assignment link still exists (a removed
assignment is skipped);
- the window is still declared on the definition (a removed window
is skipped);
- the window's live ``due_time`` still EQUALS the caller-supplied
``due_time`` (NULL-safe: NULL == NULL counts as equal; a window
with no due time matches a snapshot with no due time). A
window-time edit landed after the input snapshot makes the
caller's ``due_time`` stale, and writing it would overwrite the
newer due_time a concurrent regeneration already upserted — so
the tuple is skipped instead of written.
The due_time check closes the one check-then-insert gap this
method previously left open: it re-validated every OTHER
precondition but then wrote the snapshot's ``due_time``
unconditionally, so a stale materialization could clobber a
newer, concurrently-regenerated due_time.
Returns the stored instance, or ``None`` when a precondition
failed — the tuple no longer materializable, which the walk SKIPS
rather than raising, so the rest of the batch is unaffected. The
no-past rule (instances are never generated in the past) is
preserved from :meth:`upsert` and still raises ValueError; ``today``
optionally pins the HA-local anchor it compares against. An
instance that ALREADY has a completion event is also skipped
(returns ``None``) rather than raised: it is immutable, so the
walk leaves it untouched instead of failing the batch — the
generic :meth:`upsert` keeps its hard refusal for the
delete/regenerate path.
"""
_validate_date(due_date, "due_date")
_validate_window(window)
# Fail fast on obviously-past dates before queuing on the lock;
# the authoritative check re-runs under the lock (see upsert).
today_date = _resolve_today(today)
_reject_past(due_date, today_date.isoformat())
async with _connection_lock(self._database):
async with self._database.transaction():
if today is None:
today_date = _today()
_reject_past(due_date, today_date.isoformat())
still_valid = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions d "
"JOIN quest_definition_assignees a "
"ON a.definition_id = d.id "
"JOIN children c ON c.id = a.child_id "
"JOIN quest_definition_windows w "
"ON w.definition_id = d.id "
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
"AND d.is_active = 1 AND c.is_active = 1 "
"AND w.due_time IS ? LIMIT 1",
(definition_id, child_id, window, due_time),
)
if still_valid is None:
# A config change invalidated the tuple since the
# snapshot: skip it rather than abort the batch.
return None
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? "
"AND due_date = ? AND window = ?)",
(definition_id, child_id, due_date, window),
)
if existing is not None:
# The instance is already completed and therefore
# immutable: the walk SKIPS it (no-op) rather than
# rewriting its snapshot columns or failing the
# batch. This is the materialization walk's skip,
# distinct from the generic :meth:`upsert` refusal.
return None
await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, child_id, due_date, "
"window) DO UPDATE SET "
"due_time = excluded.due_time, "
"generated_at = excluded.generated_at",
(
definition_id,
child_id,
window,
due_date,
due_time,
generated_at,
),
)
instance = await self.get(
definition_id, child_id, due_date, window
)
assert instance is not None
return instance
async def get(
self,
definition_id: int,
child_id: int,
due_date: str,
window: str,
) -> QuestInstanceRecord | None:
"""Return the instance for the widened key tuple, or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? AND due_date = ? "
"AND window = ?",
(definition_id, child_id, due_date, window),
)
return _instance_from_row(row) if row is not None else None
async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None:
"""Return the instance with ``instance_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?",
(instance_id,),
)
return _instance_from_row(row) if row is not None else None
async def list_by_child_and_date(
self, child_id: int, due_date: str
) -> list[QuestInstanceRecord]:
"""Return the child's instances due exactly on ``due_date``."""
_validate_date(due_date, "due_date")
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date = ? ORDER BY id",
(child_id, due_date),
)
return [_instance_from_row(row) for row in rows]
async def list_by_date_range(
self,
child_id: int,
range_start: str,
range_end: str,
) -> list[QuestInstanceRecord]:
"""Return the child's instances with due_date in the closed range."""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date >= ? AND due_date <= ? "
"ORDER BY due_date, id",
(child_id, range_start, range_end),
)
return [_instance_from_row(row) for row in rows]
async def list_missed_unswept(
self, today_iso: str, watermark: str | None
) -> list[QuestInstanceRecord]:
"""Return past-due open instances the sweep has not announced.
Feature 11's missed-sweep query: ``due_date`` strictly before
``today_iso`` (past due), at or after ``watermark`` when one is
recorded, and with NO completion event ever (the Feature 08
append-only contract: an instance that was ever completed is
never reported missed, even when later reversed — history keeps
both rows and the sweep never mutates any). The watermark is
the HA-local date the sweep last ran, and the window is
``[watermark, today)``: the nightly run entering day D+1 sweeps
exactly day D's instances, so every day's quests are announced
once — at the midnight after their due date — and a same-night
rerun (watermark == today) sees an empty window. Read-only;
ordered deterministically by (due_date, id).
"""
_validate_date(today_iso, "today_iso")
if watermark is not None:
_validate_date(watermark, "watermark")
sql = (
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE due_date < ? "
"AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")"
)
parameters: tuple = (today_iso,)
if watermark is not None:
sql += " AND due_date >= ?"
parameters += (watermark,)
sql += " ORDER BY due_date, id"
rows = await self._database.fetch_all(sql, parameters)
return [_instance_from_row(row) for row in rows]
async def delete_future_uncompleted(
self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None
) -> int:
"""Delete the definition's open instances at/after cutoff.
Implements regeneration after a definition edit or reassignment
(Features 06/07): 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: "future"
is measured against TODAY (materialization regenerates the
rolling horizon from now), so a caller cannot use this to
rewrite past open instances either — the day-rollover missed
sweep (Feature 11) handles past-due instances instead. ``today``
optionally pins the HA-local anchor the clamp compares against
(falling back to the host clock when omitted). 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: the DELETE actually
# executes on this date, so a caller queued across
# midnight cannot delete instances that became past
# while it waited (backdating is clamped to the
# execution date, not the call date).
today_date = _resolve_today(today)
effective_cutoff = max(cutoff_date, today_date.isoformat())
result = await self._database.execute(
"DELETE FROM quest_instances WHERE definition_id = ? "
"AND due_date >= ? AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")",
(definition_id, effective_cutoff),
)
return result.rowcount
async def delete_future_uncompleted_for_child(
self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None
) -> tuple[int, str]:
"""Delete the child's open instances at/after cutoff, across every
definition, returning ``(deleted, effective_cutoff)``.
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). ``today`` optionally pins the HA-local anchor the clamp
compares against (falling back to the host clock when omitted).
Any instance with a completion event is never touched, regardless
of its date.
Returns the number deleted and the effective cutoff that was
actually applied (``max(cutoff_date, today)``, read under the
lock). The caller (:func:`~.materialize.regenerate_for_child`)
passes that effective cutoff back as its re-materialization anchor,
so the delete and the rebuild share ONE execution-day even when the
call is queued across midnight — instead of the caller re-reading a
stale "today" and materializing a window the delete already
superseded.
"""
_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_date = _resolve_today(today)
effective_cutoff = max(cutoff_date, today_date.isoformat())
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, effective_cutoff
class CompletionEventsDao:
"""Typed append-only access to the ``completion_events`` table.
APPEND-ONLY BY CONSTRUCTION: this class exposes ``append`` and the
three read methods below, and NOTHING else. There is no update,
no delete, no upsert — completions and un-completions both arrive
through :meth:`append` as new rows, and history is immutable
(Feature 08 done-condition: no code path updates or deletes an
event). The package-wide leak-guard test additionally fails if
mutation SQL for this table ever appears in any module.
"""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def append(
self,
instance_id: int,
child_id: int,
event_type: str,
actor_source: str,
occurred_at: str,
was_on_time: bool | None,
*,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
) -> CompletionEventRecord:
"""Append one completion or un-completion event.
``actor_source`` is 'user' (an admin action: ``actor_user_id``
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
id, and ``actor_child_id`` carrying the tapped child profile,
D-008); the actor-pair CHECKs enforce this shape at the schema
level. ``occurred_at`` must be the module's one strict UTC
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
queries stay exact. ``was_on_time`` is required (True/False)
for completions; for un-completions it may be None or the
reversed completion's flag (Feature 08 still deciding). The
instance, child and — for panel events — the tapped child must
exist — validated atomically under the connection lock.
"""
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {event_type!r}"
)
if actor_source not in ("user", "panel"):
raise ValueError(
f"actor_source must be 'user' or 'panel', got "
f"{actor_source!r}"
)
if actor_source == "user" and not actor_user_id:
raise ValueError(
"actor_source 'user' requires actor_user_id"
)
if actor_source == "panel" and actor_user_id is not None:
raise ValueError(
"actor_source 'panel' must not carry actor_user_id"
)
if actor_source == "panel" and actor_child_id is None:
raise ValueError(
"actor_source 'panel' requires actor_child_id "
"(the tapped child profile)"
)
if actor_source == "user" and actor_child_id is not None:
raise ValueError(
"actor_source 'user' must not carry actor_child_id"
)
if was_on_time is not None and type(was_on_time) is not bool:
raise ValueError(
f"was_on_time must be True, False or None, got "
f"{was_on_time!r}"
)
if event_type == EVENT_COMPLETED and was_on_time is None:
raise ValueError(
"a completed event requires was_on_time (True/False)"
)
_validate_utc_timestamp(occurred_at, "occurred_at")
async with _connection_lock(self._database):
async with self._database.transaction():
instance = await self._database.fetch_one(
"SELECT child_id FROM quest_instances WHERE id = ?",
(instance_id,),
)
if instance is None:
raise ValueError(
f"quest instance {instance_id} does not exist"
)
if instance[0] != child_id:
raise ValueError(
f"quest instance {instance_id} belongs to child "
f"{instance[0]}, not {child_id}"
)
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
if actor_child_id is not None:
tapped = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?",
(actor_child_id,),
)
if tapped is None:
raise ValueError(
f"actor_child_id {actor_child_id} does not "
"reference an existing child"
)
result = await self._database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, actor_user_id, "
"actor_child_id, occurred_at, was_on_time) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
instance_id,
child_id,
event_type,
actor_source,
actor_user_id,
actor_child_id,
occurred_at,
None if was_on_time is None else int(was_on_time),
),
)
event = await self._database.fetch_one(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE id = ?",
(result.lastrowid,),
)
assert event is not None
return _event_from_row(event)
async def list_by_instance(
self, instance_id: int
) -> list[CompletionEventRecord]:
"""Return every event for the instance, in append order."""
rows = await self._database.fetch_all(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE instance_id = ? ORDER BY id",
(instance_id,),
)
return [_event_from_row(row) for row in rows]
async def list_by_child_and_date_range(
self,
child_id: int,
range_start: str,
range_end: str,
*,
due_date_scope: bool = True,
) -> list[CompletionEventRecord]:
"""Return the child's events for instances due in the closed range.
Scopes by the INSTANCE's due date (the day the task was owed),
not the event's wall-clock timestamp: history reporting asks
"what happened to the tasks due that day" (Feature 13). Set
``due_date_scope=False`` to filter by ``occurred_at`` date
instead. Ordered by instance, then event id.
"""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
if due_date_scope:
predicate = (
"SELECT id FROM quest_instances WHERE child_id = ? "
"AND due_date >= ? AND due_date <= ?"
)
parameters: list[object] = [child_id, range_start, range_end]
else:
# Occurred-at scope: occurred_at is validated to the one
# accepted UTC shape at append time, so half-open day
# bounds in that same shape are exact (no spelling drift).
occurred_start = f"{range_start}T00:00:00+00:00"
occurred_end = f"{range_end}T23:59:59+00:00"
query = (
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE child_id = ? AND occurred_at >= ? "
"AND occurred_at < ? ORDER BY instance_id, id"
)
rows = await self._database.fetch_all(
query,
[
child_id,
occurred_start,
_next_day_start(range_end),
],
)
return [_event_from_row(row) for row in rows]
rows = await self._database.fetch_all(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
f"WHERE instance_id IN ({predicate}) "
"ORDER BY instance_id, id",
tuple(parameters),
)
return [_event_from_row(row) for row in rows]
async def get_latest_for_instance(
self, instance_id: int
) -> CompletionEventRecord | None:
"""Return the instance's most recent event, or None if untouched.
Instance current state derives from this event (Feature 08):
'completed' means done, 'uncompleted' as the latest means open
again, None means never completed.
"""
row = await self._database.fetch_one(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE instance_id = ? ORDER BY id DESC LIMIT 1",
(instance_id,),
)
return _event_from_row(row) if row is not None else None
from .core.dao_instances import * # noqa: F401,F403
from .core.dao_instances import _resolve_today # noqa: F401
+7 -40
View File
@@ -1,44 +1,11 @@
"""Typed DAO for the integration-owned ``nestquest_meta_state`` table.
"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package.
Generic persistent key/value bookkeeping that belongs to the
integration itself rather than any domain table — currently the
Feature 11 missed-sweep watermark. Values are plain strings; callers
interpret them. All SQL for this table lives here per the feature
guardrails, and every method runs through
:class:`~.db.NestQuestDatabase` so nothing blocks the event loop.
The meta-state DAO lives in
:mod:`custom_components.nestquest.core.dao_meta` (extracted in task
d0b691d8); this module re-exports it so existing
``from .dao_meta import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .dao_children import _connection_lock
from .db import NestQuestDatabase
_TABLE = "nestquest_meta_state"
class MetaStateDao:
"""Typed async access to the ``nestquest_meta_state`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def get(self, key: str) -> str | None:
"""Return the stored value for ``key``, or None when absent."""
row = await self._database.fetch_one(
f"SELECT value FROM {_TABLE} WHERE key = ?", (key,)
)
return row[0] if row is not None else None
async def set(self, key: str, value: str) -> None:
"""Upsert one key/value row atomically.
The write runs inside one transaction under the shared
connection lock, so a racing reader either sees the old value
or the fully-written new one, never a half-applied state.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
await self._database.execute(
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
(key, value),
)
from .core.dao_meta import * # noqa: F401,F403
+7 -339
View File
@@ -1,343 +1,11 @@
"""Typed DAO layer for the presence_schedules and presence_overrides tables.
"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Semantics per the done-condition and Feature 05:
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting
by child updates the existing row rather than raising.
- A child with NO schedule row is present every day — the absence of a
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child`
returning None, never by an empty pattern row.
- Overrides beat the pattern for their date range (that resolution is
the presence engine's job, Feature 05); the DAO only stores and
lists them. A single-day override stores the same date as both
start and end (schema CHECK requires end >= start).
Date arguments are validated as strict YYYY-MM-DD per the same policy
the rules DAO applies: the schema's lexical CHECKs cannot prove
calendar validity.
The presence DAOs live in
:mod:`custom_components.nestquest.core.dao_presence` (extracted in
task d0b691d8); this module re-exports them so existing
``from .dao_presence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from dataclasses import dataclass
from .dao_children import _connection_lock
from .dao_rules import _UNSET, _validate_date
from .db import NestQuestDatabase
@dataclass(frozen=True)
class PresenceScheduleRecord:
"""One row of ``presence_schedules``."""
id: int
child_id: int
cycle_length_weeks: int
anchor_date: str
pattern: str
@dataclass(frozen=True)
class PresenceOverrideRecord:
"""One row of ``presence_overrides``."""
id: int
child_id: int
start_date: str
end_date: str
is_present: bool
note: str | None
_SCHEDULE_COLUMNS = (
"id, child_id, cycle_length_weeks, anchor_date, pattern"
)
_OVERRIDE_COLUMNS = (
"id, child_id, start_date, end_date, is_present, note"
)
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord:
return PresenceScheduleRecord(
id=row[0],
child_id=row[1],
cycle_length_weeks=row[2],
anchor_date=row[3],
pattern=row[4],
)
def _override_from_row(row: tuple) -> PresenceOverrideRecord:
return PresenceOverrideRecord(
id=row[0],
child_id=row[1],
start_date=row[2],
end_date=row[3],
is_present=bool(row[4]),
note=row[5],
)
class PresenceSchedulesDao:
"""Typed async access to the ``presence_schedules`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert_by_child(
self,
child_id: int,
cycle_length_weeks: int,
anchor_date: str,
pattern: str,
) -> PresenceScheduleRecord:
"""Set the child's presence schedule, replacing any existing one.
Upsert semantics are the done-condition: a second schedule for
the same child updates the existing row rather than raising.
Runs under the connection lock so a concurrent upsert/delete
for the same child serializes instead of colliding on the
schema's UNIQUE(child_id). Pattern validity (segment shape,
segment count = cycle_length_weeks) is enforced by the schema
CHECKs; the child must exist (FK) — validated atomically here
so a deleted child cannot pass a stale check.
"""
_validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database):
async with self._database.transaction():
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
await self._database.execute(
"INSERT INTO presence_schedules (child_id, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT (child_id) DO UPDATE SET "
"cycle_length_weeks = excluded.cycle_length_weeks, "
"anchor_date = excluded.anchor_date, "
"pattern = excluded.pattern",
(child_id, cycle_length_weeks, anchor_date, pattern),
)
schedule = await self.get_by_child(child_id)
assert schedule is not None
return schedule
async def get_by_child(
self, child_id: int
) -> PresenceScheduleRecord | None:
"""Return the child's schedule, or None when the child has none.
None is meaningful and UNAMBIGUOUS here: the child exists and
has no schedule row, i.e. the child is present every day
(Feature 05 guardrail). An unknown child id raises ValueError
instead of returning None, because "nonexistent child" must
never be silently read as "present every day".
"""
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
row = await self._database.fetch_one(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
"WHERE child_id = ?",
(child_id,),
)
return _schedule_from_row(row) if row is not None else None
async def delete(self, child_id: int) -> bool:
"""Remove the child's schedule; True if a row was removed.
After deletion the child is present every day (no row means
no restriction). Deleting a schedule the child never had
returns False.
"""
result = await self._database.execute(
"DELETE FROM presence_schedules WHERE child_id = ?",
(child_id,),
)
return result.rowcount > 0
class PresenceOverridesDao:
"""Typed async access to the ``presence_overrides`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def create(
self,
child_id: int,
start_date: str,
end_date: str,
is_present: bool,
*,
note: str | None = None,
) -> PresenceOverrideRecord:
"""Insert one override and return the record as stored.
The child must exist (validated atomically under the
connection lock). Date shape is validated strictly, and the
schema CHECK rejects end < start.
Overlap policy (Feature 05): an override for a child may not
overlap another override of the SAME child — two different
present/absent answers for one date are unresolvable. The
overlap check (inclusive boundaries) runs inside the same
transaction as the INSERT, so a racing create cannot slip a
conflicting override between the check and the write; a
conflict raises ValueError naming the conflicting override's
id and range.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
async with _connection_lock(self._database):
async with self._database.transaction():
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
# Only a well-ordered range has overlap semantics:
# inverted ranges fall through to the INSERT and hit
# the schema's end>=start CHECK, preserving the
# IntegrityError contract the matrix tests rely on.
if end_date >= start_date:
conflicting = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} "
"FROM presence_overrides "
"WHERE child_id = ? AND end_date >= ? "
"AND start_date <= ? ORDER BY id LIMIT 1",
(child_id, start_date, end_date),
)
else:
conflicting = None
if conflicting is not None:
conflict = _override_from_row(conflicting)
raise ValueError(
f"override for child {child_id} "
f"[{start_date}, {end_date}] overlaps existing "
f"override {conflict.id} "
f"[{conflict.start_date}, {conflict.end_date}] "
f"(is_present={conflict.is_present})"
)
result = await self._database.execute(
"INSERT INTO presence_overrides (child_id, "
"start_date, end_date, is_present, note) "
"VALUES (?, ?, ?, ?, ?)",
(child_id, start_date, end_date, int(is_present), note),
)
override = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE id = ?",
(result.lastrowid,),
)
assert override is not None
return _override_from_row(override)
async def list_by_child_and_range(
self,
child_id: int,
range_start: str,
range_end: str,
) -> list[PresenceOverrideRecord]:
"""Return the child's overrides overlapping [range_start, range_end].
An override overlaps the range when it ends on or after the
range starts and starts on or before the range ends (inclusive
boundary dates, matching the storage model where overrides are
closed date ranges). Ordered by start date.
"""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
rows = await self._database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE child_id = ? AND end_date >= ? AND start_date <= ? "
"ORDER BY start_date",
(child_id, range_start, range_end),
)
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(
"DELETE FROM presence_overrides WHERE id = ?",
(override_id,),
)
return result.rowcount > 0
async def read_snapshot_unlocked(
database: NestQuestDatabase,
child_ids: list[int],
range_start: str,
range_end: str,
) -> tuple[
dict[int, PresenceScheduleRecord],
dict[int, list[PresenceOverrideRecord]],
]:
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
MUST be called inside the connection lock and an open transaction, so
the schedules and overrides returned here stay coherent with every
other table the caller reads inside that same transaction (the
materialization walk's single input snapshot does exactly that). The
date range comes already validated by the caller.
Returns ``(schedules, overrides)``:
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
for every child that HAS a schedule row. A child absent from the
mapping has no schedule, i.e. is present every day (Feature 05).
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
``[range_start, range_end]`` (inclusive boundaries), ordered by start
date; a child with no matching override is absent from the mapping.
``child_ids`` must name existing children (the caller derives them
from a trusted snapshot, within the same transaction).
"""
children = sorted(set(child_ids))
if not children:
return {}, {}
placeholders = ",".join("?" for _ in children)
schedule_rows = await database.fetch_all(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
f"WHERE child_id IN ({placeholders})",
tuple(children),
)
override_rows = await database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
f"WHERE child_id IN ({placeholders}) AND end_date >= ? "
"AND start_date <= ? ORDER BY start_date",
tuple(children) + (range_start, range_end),
)
schedules = {
record.child_id: record
for record in map(_schedule_from_row, schedule_rows)
}
overrides: dict[int, list[PresenceOverrideRecord]] = {}
for row in override_rows:
record = _override_from_row(row)
overrides.setdefault(record.child_id, []).append(record)
return schedules, overrides
from .core.dao_presence import * # noqa: F401,F403
File diff suppressed because it is too large Load Diff
+23 -414
View File
@@ -1,422 +1,31 @@
"""Async SQLite connection wrapper for the NestQuest integration."""
"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package.
The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task
d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes
an executor callable rather than a ``hass`` object. This module
re-exports it and provides :func:`make_database`, a thin adapter that
constructs a wrapper backed by ``hass.async_add_executor_job``, so the
integration constructs the database the same way it always did.
"""
from __future__ import annotations
import asyncio
import sqlite3
from collections.abc import AsyncIterator, Awaitable, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import Any, TypeVar
from homeassistant.core import HomeAssistant
from .core.db import ExecutionResult, NestQuestDatabase
@dataclass(frozen=True)
class ExecutionResult:
"""Plain-data outcome of one statement.
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
``rowcount`` and ``lastrowid`` mirror the corresponding
:class:`sqlite3.Cursor` attributes; both are captured on the executor
thread so no sqlite3 object ever crosses back to the event loop.
T = TypeVar("T")
def make_database(hass: HomeAssistant) -> NestQuestDatabase:
"""Construct a :class:`NestQuestDatabase` wired to ``hass``'s executor.
Mirrors the pre-extraction ``NestQuestDatabase(hass)`` call: the
wrapper's executor is ``hass.async_add_executor_job``, so every
sqlite3 call runs on the HA executor thread and the event loop
never blocks.
"""
rowcount: int
lastrowid: int | None
class NestQuestDatabase:
"""Async wrapper around a single SQLite connection.
Every sqlite3 call is delegated to the Home Assistant executor through
``hass.async_add_executor_job`` so the event loop is never blocked, and
only plain Python data (row tuples and :class:`ExecutionResult`) is
returned to the event loop.
Every operation is serialized through one asyncio.Lock per wrapper. A
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
interleaved coroutines cannot corrupt the transaction and concurrent
BEGINs are impossible; statements issued while a transaction is open
queue until it completes. Concurrent transactions on one wrapper are
not supported: a second ``transaction()`` entered while one is active
raises :class:`RuntimeError`. ``close()`` serializes on the same lock
and is idempotent.
Every connection-affecting executor job (connect, pragmas, statements,
transaction lifecycle and close) is awaited through the settlement
helper :meth:`_await_settled`: if the awaiting task is cancelled while
a job is in flight, the job still settles on the worker thread before
the cancellation propagates, so the wrapper lock is never released
while the executor thread is mid-job.
"""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the wrapper around a Home Assistant instance."""
self._hass = hass
self._conn: sqlite3.Connection | None = None
self._rowcount: int = -1
self._lastrowid: int | None = None
self._lock: asyncio.Lock | None = None
self._lock_loop: asyncio.AbstractEventLoop | None = None
self._in_transaction = False
self._tx_task: asyncio.Task | None = None
def _get_lock(self) -> asyncio.Lock:
"""Return the per-wrapper lock, (re)bound to the running loop.
HA drives every wrapper call from one event loop, so the lock is
created once and reused; re-binding only when the running loop
differs keeps the wrapper usable from plain synchronous drivers
that run each call on a fresh loop.
"""
loop = asyncio.get_running_loop()
if self._lock is None or self._lock_loop is not loop:
self._lock = asyncio.Lock()
self._lock_loop = loop
return self._lock
@property
def connected(self) -> bool:
"""Return True while the underlying connection is open."""
return self._conn is not None
@property
def rowcount(self) -> int:
"""Row count of the most recent statement, or -1 when unset."""
return self._rowcount
@property
def lastrowid(self) -> int | None:
"""Row id of the most recent insert, or None when unset."""
return self._lastrowid
@asynccontextmanager
async def _serialized(self) -> AsyncIterator[None]:
"""Hold the wrapper lock unless the caller owns the active transaction.
A transaction holds the lock for its whole BEGIN..COMMIT span;
statements issued by the transaction's own task are already
serialized by it and must not re-acquire the non-reentrant lock.
Any other task queues on the lock until the transaction ends.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
yield
return
async with self._get_lock():
yield
async def open(self, path: Path | str) -> NestQuestDatabase:
"""Open the SQLite file with WAL journal mode and foreign keys on.
Cancellation-safe: if this call is cancelled while the connect job
is in flight, the job still settles (see :meth:`_await_settled`).
A connection the settled job created is never adopted by the
cancelled call; it is closed through a settled executor job instead
of being abandoned to garbage collection, and the cancellation
propagates afterwards, so a follow-up ``open()`` starts from a
clean state.
"""
if self._conn is not None:
return self
async with self._get_lock():
if self._conn is not None:
return self
def _open() -> sqlite3.Connection:
conn = sqlite3.connect(
path, check_same_thread=False, isolation_level=None
)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
except BaseException:
conn.close()
raise
return conn
settled, cancelled = await self._await_settled_state(
self._hass.async_add_executor_job(_open)
)
if cancelled:
if isinstance(settled, sqlite3.Connection):
def _close_abandoned() -> None:
settled.close()
await self._await_settled(
self._hass.async_add_executor_job(_close_abandoned)
)
raise asyncio.CancelledError()
self._conn = settled
return self
async def execute(
self, sql: str, parameters: Sequence[Any] = ()
) -> ExecutionResult:
"""Run one statement in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute() -> ExecutionResult:
cursor = conn.execute(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._hass.async_add_executor_job(_execute)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def execute_many(
self, sql: str, parameters: Sequence[Sequence[Any]]
) -> ExecutionResult:
"""Run executemany in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute_many() -> ExecutionResult:
cursor = conn.executemany(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._hass.async_add_executor_job(_execute_many)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def fetch_one(
self, sql: str, parameters: Sequence[Any] = ()
) -> tuple | None:
"""Run a query in the executor and return its first row, or None."""
async with self._serialized():
conn = self._require_conn()
def _fetch_one() -> tuple[tuple | None, int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
row, rowcount, lastrowid = await self._await_settled(
self._hass.async_add_executor_job(_fetch_one)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return row
async def fetch_all(
self, sql: str, parameters: Sequence[Any] = ()
) -> list[tuple]:
"""Run a query in the executor and return all of its rows."""
async with self._serialized():
conn = self._require_conn()
def _fetch_all() -> tuple[list[tuple], int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
rows, rowcount, lastrowid = await self._await_settled(
self._hass.async_add_executor_job(_fetch_all)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return rows
@asynccontextmanager
async def transaction(self) -> AsyncIterator[None]:
"""Run a block inside BEGIN/COMMIT, rolling back on any exception.
The wrapper lock is held for the entire BEGIN..COMMIT span, so no
other statement can interleave inside the transaction. Entering a
second transaction while one is active raises RuntimeError. A
failing COMMIT (e.g. a deferred foreign-key violation) also triggers
a ROLLBACK before the commit error propagates, so the real
connection is never left inside a stray transaction.
The lifecycle is cancellation-safe. Transaction ownership and the
wrapper lock are only released after the executor work has fully
settled, even when the owning task is cancelled mid-flight: if a
cancelled BEGIN or COMMIT still completes on the worker thread,
the corresponding ROLLBACK is shielded and awaited before any state
is cleared. A lifecycle statement that fails plus a failing
follow-up cleanup surface both errors in an ExceptionGroup.
"""
if self._in_transaction:
raise RuntimeError(
"A NestQuest transaction is already active on this "
"connection; concurrent transactions are not supported"
)
async with self._get_lock():
conn = self._require_conn()
self._in_transaction = True
self._tx_task = asyncio.current_task()
try:
def _begin() -> None:
conn.execute("BEGIN")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_begin),
"BEGIN did not settle cleanly and the follow-up "
"cleanup also raised; the transaction may need manual "
"cleanup",
)
try:
yield
except BaseException:
def _rollback() -> None:
conn.execute("ROLLBACK")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_rollback),
"ROLLBACK failed and the follow-up cleanup also "
"raised; the transaction may need manual cleanup",
)
raise
def _commit() -> None:
conn.execute("COMMIT")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_commit),
"COMMIT failed and the follow-up cleanup also raised; "
"the transaction may need manual cleanup",
)
finally:
self._in_transaction = False
self._tx_task = None
async def _settle_lifecycle(
self, conn: sqlite3.Connection, job: Awaitable[Any], message: str
) -> None:
"""Run one BEGIN/ROLLBACK/COMMIT job, then force the connection clean.
The job always settles first (see :meth:`_await_settled`), so a
cancelled statement that the worker thread completed anyway is
never abandoned mid-flight. If the job raised, a follow-up
ROLLBACK — itself settled — guarantees the connection ends outside
any SQLite transaction before the error propagates. When both the
job and the cleanup raise, both errors are preserved in an
ExceptionGroup instead of the original being lost.
"""
try:
await self._await_settled(job)
except BaseException as settle_error:
errors: list[BaseException] = [settle_error]
try:
await self._ensure_no_stray_transaction(conn)
except BaseException as cleanup_error:
errors.append(cleanup_error)
if len(errors) == 2:
raise BaseExceptionGroup(message, errors) from settle_error
raise
async def _ensure_no_stray_transaction(
self, conn: sqlite3.Connection
) -> None:
"""Force the connection out of any transaction via ROLLBACK.
Runs after a failed or cancelled lifecycle statement. The worker
thread may still be finishing (or may already have completed) the
very statement whose await was cancelled, so both the check and
the ROLLBACK settle in the executor before the caller releases
transaction ownership.
"""
def _cleanup() -> bool:
if conn.in_transaction:
conn.execute("ROLLBACK")
return conn.in_transaction
stray = await self._await_settled(
self._hass.async_add_executor_job(_cleanup)
)
if stray:
raise RuntimeError(
"The SQLite connection is still inside a transaction after "
"cleanup; the transaction may need manual cleanup"
)
async def _await_settled(self, job: Awaitable[Any]) -> Any:
"""Await ``job``, letting it settle even if this task is cancelled.
Cancellation is observed, not swallowed: it is re-raised only once
the job has fully settled, so the caller can finalize against a
known connection state instead of abandoning in-flight work. The
job is wrapped in a task because a bare coroutine dies un-awaited
once its await is cancelled. A second cancellation during
settlement is absorbed the same way; if the job itself fails after
a cancellation, both errors surface in an ExceptionGroup.
"""
result, cancelled = await self._await_settled_state(job)
if cancelled:
raise asyncio.CancelledError()
return result
async def _await_settled_state(self, job: Awaitable[Any]) -> tuple[Any, bool]:
"""Await ``job`` to settlement and report ``(result, cancelled)``.
Same settlement guarantees as :meth:`_await_settled`, but returns
the settled outcome together with whether the awaiting task was
cancelled, so callers that must act on a settled result before
re-raising cancellation (e.g. closing an abandoned connection)
can do so without losing the result.
"""
cancelled = False
inner = asyncio.ensure_future(job)
while not inner.done():
try:
await asyncio.shield(inner)
except asyncio.CancelledError:
cancelled = True
try:
result = inner.result()
except BaseException as job_error:
if cancelled:
raise BaseExceptionGroup(
"executor job failed while settling after cancellation",
[asyncio.CancelledError(), job_error],
) from job_error
raise
return result, cancelled
async def close(self) -> None:
"""Close the connection in the executor; safe to call repeatedly.
Serialized on the wrapper lock, so an in-flight operation or open
transaction always completes before the connection is closed.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
raise RuntimeError(
"Cannot close the NestQuest database from inside its own "
"transaction; exit the transaction first"
)
async with self._get_lock():
conn, self._conn = self._conn, None
self._rowcount, self._lastrowid = -1, None
if conn is None:
return
def _close() -> None:
conn.close()
await self._await_settled(self._hass.async_add_executor_job(_close))
def _require_conn(self) -> sqlite3.Connection:
if self._conn is None:
raise RuntimeError(
"The NestQuest database connection is closed; call open() first"
)
return self._conn
return NestQuestDatabase(hass.async_add_executor_job)
+49 -100
View File
@@ -1,73 +1,37 @@
"""HA bus events fired by the NestQuest service paths (Feature 10).
"""Re-export / HA wiring of :mod:`custom_components.nestquest.core.events` for the integration package.
The four documented events (design/ENTITIES-AND-SERVICES.md §3, names
in :mod:`~.const`): ``nestquest_quest_completed`` and
``nestquest_quest_uncompleted`` fire from the completion service
handlers on an actual transition (a no-op re-complete or re-uncomplete
fires nothing), and ``nestquest_child_day_complete`` fires when a
completion clears the child's whole day (quests were owed and none
remain — a zero-quest day can never fire it, matching the all-done
binary sensor's rule). ``nestquest_quest_missed`` is the documented
contract Feature 11's nightly sweep fires; nothing here fires it.
The transition-event payload construction lives in
:mod:`custom_components.nestquest.core.events` (extracted in
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
shim keeps the integration's old ``fire_quest_completed`` /
``fire_quest_uncompleted`` API (the complete/uncomplete service
handlers call them with ``hass``), reads the HA-local timezone from
``hass.config.time_zone`` to derive ``today``, asks the core builder
for the events, and fires each on ``hass.bus`` with the documented
payloads. The four blueprints keep working with identical payloads.
Payload policy: one dict carrying ``child_id``, ``child_name``,
``instance_id``, ``quest_title``, the instance's ``window``/
``due_date``/``due_time`` so automation authors can filter without a
database lookup, and ``occurred_at`` — the event's own strict UTC
ISO-8601 timestamp. Completion payloads additionally carry
``was_on_time``. No other personal data: names and titles are the
household's own records, and nothing beyond them leaks.
The completed event is fired BEFORE the day-complete rule evaluates:
``fire_quest_completed`` asks
:func:`build_quest_completed_event` for the completed event and fires
it immediately, THEN reads the HA-local ``today``, asks
:func:`build_child_day_complete_event` for the day-complete event, and
fires it (if any) afterwards. This restores the pre-extraction
ordering — a failure in the day-complete DB reads or the ZoneInfo
lookup cannot suppress the completed event.
"""
from __future__ import annotations
import datetime
from zoneinfo import ZoneInfo
from .completion import derive_state
from .const import (
EVENT_CHILD_DAY_COMPLETE,
EVENT_QUEST_COMPLETED,
EVENT_QUEST_UNCOMPLETED,
from .core.db import NestQuestDatabase
from .core.events import (
build_child_day_complete_event,
build_quest_completed_event,
build_quest_uncompleted_events,
)
from .dao_children import ChildrenDao
from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
#: explicit offset, second precision.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
async def _instance_payload(
database: NestQuestDatabase, instance_id: int, now_stamp: str
) -> dict:
"""Build the shared event payload for one quest instance."""
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
child = await ChildrenDao(database).get(instance.child_id)
definition = await QuestDefinitionsDao(database).get(
instance.definition_id
)
return {
"child_id": instance.child_id,
"child_name": child.display_name if child else None,
"instance_id": instance.id,
"quest_title": definition.title if definition else None,
"window": instance.window,
"due_date": instance.due_date,
"due_time": instance.due_time,
"occurred_at": now_stamp,
}
__all__ = ["fire_quest_completed", "fire_quest_uncompleted"]
async def fire_quest_completed(
@@ -80,51 +44,36 @@ async def fire_quest_completed(
"""Fire ``nestquest_quest_completed`` and, when the completion
cleared the child's whole day, ``nestquest_child_day_complete``.
Called by the complete_quest service handler AFTER an actual
completion transition, with the completion's ``was_on_time`` —
computed under the completion lock and carried on the result — so
the payload never re-reads mutable latest state a racing reversal
could have replaced. A no-op re-complete never reaches here.
Thin HA wiring over the HA-free
:func:`custom_components.nestquest.core.events.build_quest_completed_event`
and
:func:`build_child_day_complete_event`: the completed event is
built and fired FIRST (so a failure in the day-complete DB reads
or the HA-local ``today`` lookup cannot suppress it), then the
HA-local ``today`` (the day-complete rule's input) is read from
``hass.config.time_zone`` and the day-complete builder is asked
whether the child's whole day cleared. Identical payloads and
day-complete rule to the pre-extraction implementation.
"""
now_stamp = _now_stamp()
payload = await _instance_payload(database, instance_id, now_stamp)
payload["was_on_time"] = was_on_time
hass.bus.async_fire(EVENT_QUEST_COMPLETED, payload)
# Day-complete: quests were owed today and none remain (the same
# rule as the all-done binary sensor; a zero-quest day never fires).
# Only a completion of TODAY'S instance evaluates it: completing a
# back-dated or future instance must not announce a cleared day.
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
if payload["due_date"] != today.isoformat():
return
instances = await QuestInstancesDao(database).list_by_date_range(
payload["child_id"], today.isoformat(), today.isoformat()
completed = await build_quest_completed_event(
database,
instance_id,
was_on_time=was_on_time,
)
events = CompletionEventsDao(database)
remaining = 0
for instance in instances:
latest_for_instance = await events.get_latest_for_instance(
instance.id
)
if derive_state(instance, latest_for_instance, today) != "done":
remaining += 1
if instances and remaining == 0:
hass.bus.async_fire(
EVENT_CHILD_DAY_COMPLETE,
{
"child_id": payload["child_id"],
"child_name": payload["child_name"],
"quests_due": len(instances),
"quests_completed": len(instances),
"occurred_at": now_stamp,
},
)
hass.bus.async_fire(*completed)
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
day_complete = await build_child_day_complete_event(
database, completed[1], today=today
)
if day_complete is not None:
hass.bus.async_fire(*day_complete)
async def fire_quest_uncompleted(
hass, database: NestQuestDatabase, instance_id: int
) -> None:
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
payload = await _instance_payload(database, instance_id, _now_stamp())
hass.bus.async_fire(EVENT_QUEST_UNCOMPLETED, payload)
events = await build_quest_uncompleted_events(database, instance_id)
for event_type, payload in events:
hass.bus.async_fire(event_type, payload)
+7 -373
View File
@@ -1,377 +1,11 @@
"""The materialization walk (Feature 07 core).
"""Re-export of :mod:`custom_components.nestquest.core.materialize` for the integration package.
:func:`materialize` turns the schedule rules + custody presence of the
active quest definitions into concrete ``quest_instances`` rows over a
date range. It is the pure walk over definitions x dates x assignees x
windows; HA scheduling (day-rollover triggers) is a separate later
task and lives elsewhere.
The walk is deterministic and idempotent:
- All inputs — the active definitions with their rules, assignees and
windows, AND the assignee children's presence schedules and scoped
overrides — are read inside ONE locked transaction (see
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
single coherent snapshot rather than a definition state from one
instant and a presence state from another.
- Presence is snapshotted into an immutable
:class:`~.presence.PresenceEngine` from that same coherent read.
- For each date in the range, each definition whose decoded rule
:func:`~.recurrence.occurs_on` on that date, each assignee who is
``is_present`` on that date and each declared window, one instance is
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
A config change that lands AFTER the snapshot is read but BEFORE a
tuple's write (an assignment removed, a definition or child deactivated,
a window removed) makes that tuple SKIP rather than abort the batch:
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
every materialization precondition (active definition, active child,
live assignment, declared window, and its CURRENT ``due_time``) inside
the SAME transaction as the INSERT, so the check and the write cannot be
split by a racing config edit.
The upsert is idempotent on (definition_id, child_id, due_date,
window), so re-running the materialization over the same range never
duplicates a row; a tuple whose instance is already completed is SKIPPED
(never rewritten, never raised) by
: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 + horizon_days]`` (``horizon_days`` defaults to
``DEFAULT_HORIZON_DAYS``).
HOOK (Feature 09 presence services): the presence business layer that
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
presence-state write so a child's future instances track its presence:
``set_presence_pattern`` (including clearing/setting an empty pattern),
deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
``create_presence_override``, and ``delete_presence_override``. A
schedule delete changes presence state too (the child becomes
always-present), so it must regenerate just like the others. 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.
The materialization walk lives in
:mod:`custom_components.nestquest.core.materialize` (extracted in
task 510c1f78); this module re-exports it so existing
``from .materialize import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import datetime
from .const import DEFAULT_HORIZON_DAYS
from .dao_instances import QuestInstancesDao, _resolve_today
from .dao_rules import (
_validate_date,
load_materialization_input,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
)
from .db import NestQuestDatabase
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
from .recurrence import ScheduleRule, occurs_on
def _now_stamp() -> str:
"""Return the batch's shared UTC second-precision generation stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _resolve_horizon_days(horizon_days: int | None) -> int:
"""Validate a regeneration horizon and return the resolved day count.
``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool
(SQLite would happily bind ``True``/``False`` onto an int), a non-int,
or a value below 1 raises ValueError naming the field — the validation
runs BEFORE any future-instance delete so a malformed horizon can never
delete rows it then fails to rebuild. A negative horizon would
otherwise delete first and only then be rejected by
:func:`materialize`'s inverted-range check; ``0``/``False`` would
silently shrink the window to a single day.
"""
if horizon_days is None:
return DEFAULT_HORIZON_DAYS
if (
isinstance(horizon_days, bool)
or not isinstance(horizon_days, int)
or horizon_days < 1
):
raise ValueError(
f"horizon_days must be a positive integer, got {horizon_days!r}"
)
return horizon_days
def _decode_rule(snapshot) -> ScheduleRule:
"""Decode a snapshot's rule record back into a ScheduleRule.
The record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through ``ScheduleRulesDao``) preserves the coherence the snapshot
guarantees.
"""
return schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
def _build_engine(
schedules_records,
overrides_records,
) -> PresenceEngine:
"""Convert raw presence records into an immutable PresenceEngine."""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
child_id, record.anchor_date, record.pattern
)
overrides: dict[int, list[PresenceOverride]] = {}
for child_id, records in overrides_records.items():
overrides[child_id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in records
]
return PresenceEngine(schedules, overrides)
async def materialize(
database: NestQuestDatabase,
start_date: str,
end_date: str,
*,
child_ids: list[int] | None = None,
today: datetime.date | None = None,
) -> int:
"""Generate quest instances for the closed range [start_date, end_date].
``start_date`` and ``end_date`` must be strict ISO calendar dates
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
else raises ValueError naming the offending field. Returns the number
of instances upserted for the range (idempotent — a re-run refreshes
the same rows, never duplicates them).
``child_ids`` optionally scopes the walk to exactly those children:
assignees outside the list are never written, so a caller that only
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
after a presence change) cannot touch any unrelated child's rows.
When omitted (or None) the walk covers every assignee as before.
``today`` optionally pins the resolved HA-local date the walk's
no-past guard compares against; it is threaded into every
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
omitted the host clock (``datetime.date.today``) is used, which is the
historical default. A caller that computed the horizon in Home
Assistant's configured time zone MUST pass that same date here so a
time zone behind the host around midnight is not rejected as "past".
``start_date`` is clamped up to the resolved "today" (the same
``today`` anchor threaded into the no-past guard), so a caller that
passes a ``start_date`` already in the past never yields a
past-dated instance: the walk simply begins on today and skips every
earlier date. A range entirely in the past therefore produces zero
instances rather than an error, and the DAO's no-past guard remains
the authoritative backstop under the lock.
All inputs are read in ONE locked transaction; each tuple's write then
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
which re-validates the tuple and its current ``due_time`` in the same
transaction as the INSERT, so a config change that arrives mid-batch
skips the affected tuples rather than aborting the batch or leaving a
partial result.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
if end_date < start_date:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end_date!r} < {start_date!r}"
)
snapshots, schedules_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
engine = _build_engine(schedules_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids)
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows)
for snapshot in snapshots
]
generated_at = _now_stamp()
instances = QuestInstancesDao(database)
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
# Clamp a past start up to today (the HA-local anchor when the caller
# pinned one, else the host clock) so the walk can never emit a
# past-dated instance; the DAO's no-past guard remains the backstop.
# A range entirely in the past therefore yields zero instances.
today_date = _resolve_today(today)
start = max(start, today_date)
count = 0
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for definition_id, rule, assignees, windows in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if not engine.is_present(child.id, cursor):
continue
for window in windows:
written = await instances.upsert_if_valid(
definition_id,
child.id,
iso,
generated_at,
window=window.window,
due_time=window.due_time,
today=today,
)
if written is not None:
count += 1
cursor += datetime.timedelta(days=1)
return count
async def regenerate_for_definition(
database: NestQuestDatabase,
definition_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a definition's future instances after a config change.
The Feature 06 edit/assignment/activation paths call this after a
successful write so future instances track the new state: the
definition's open instances at or after today are deleted (never a
completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
then the materialization walk re-runs over the rolling horizon
``[today, today + horizon_days]`` so the (changed) rule,
assignee set and windows re-materialize against today's state.
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — so the delete cutoff and the
re-materialization range share one anchor, and the walk's
``upsert_if_valid`` no-past guard never rejects the regenerated
range. ``today`` optionally pins a caller-resolved HA-local date
(threaded into both the delete cutoff and the re-materialization)
so a household time zone behind the host around midnight stays
consistent. ``horizon_days`` sizes the re-materialization window;
when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`.
A bool, non-int, or sub-1 value raises ValueError BEFORE any delete.
Returns the number of instances the re-materialization
upserted across the whole snapshot (idempotent — other active
definitions' tuples refresh in place, never duplicate).
"""
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
start_date = today_date.isoformat()
end_date = (
today_date + datetime.timedelta(days=horizon)
).isoformat()
instances = QuestInstancesDao(database)
await instances.delete_future_uncompleted(
definition_id, start_date, today=today
)
return await materialize(database, start_date, end_date, today=today)
# 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 MUST call
# ``regenerate_for_child`` after EVERY presence-state write, so future
# instances track a child's changed presence: set_presence_pattern
# (including clearing/setting an empty pattern), deleting/clearing the
# presence schedule (PresenceSchedulesDao.delete),
# create_presence_override, and delete_presence_override. A schedule
# delete changes presence state too (the child becomes always-present),
# so it must regenerate just like the others. 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 above.
async def regenerate_for_child(
database: NestQuestDatabase,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a child's future instances after a presence change.
The Feature 09 presence services call this after every
presence-state write — set_presence_pattern (including clearing or
setting an empty pattern), deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
create_presence_override, and delete_presence_override — 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 + horizon_days]`` scoped to that child only,
so the child's now-absent/present dates re-materialize against
today's presence WITHOUT touching any other child's rows.
``child_id`` must be a plain int — bool and float are rejected
(SQLite binds a bool as 0/1 and a float would round, so a malformed
id must never mutate the wrong profile).
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — and the re-materialization range uses the
delete's returned effective cutoff (see
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
so the delete cutoff and the re-materialization window share ONE
execution-day anchor even when the call is queued across midnight.
``today`` optionally pins a caller-resolved HA-local date, threaded
into the delete cutoff and the re-materialization for the same
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
re-materialization window; when omitted it falls back to
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
raises ValueError BEFORE any delete. Returns the number of instances
the re-materialization upserted for the child across the whole
snapshot.
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
instances = QuestInstancesDao(database)
# Use the delete's returned effective cutoff as the materialize anchor,
# NOT the independently-computed "today" above. If the call is queued
# across midnight the delete clamps its cutoff to the execution-day;
# materializing from the stale "today" (now yesterday) would trip the
# walk's no-past guard and leave the child's future instances deleted
# but not rebuilt. One anchor for both keeps them consistent.
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
child_id, today_date.isoformat(), today=today
)
start = datetime.date.fromisoformat(effective_cutoff)
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
return await materialize(
database, start.isoformat(), end_date, child_ids=[child_id], today=today
)
from .core.materialize import * # noqa: F401,F403
+7 -589
View File
@@ -1,593 +1,11 @@
"""Versioned schema migration runner for the NestQuest SQLite database.
"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package.
The runner owns one metadata table, ``nestquest_schema_version``, holding
a single row with the schema version the database file currently
reflects. Migrations are declared as an ordered list: entry ``n``
upgrades version ``n-1`` to version ``n``. Applying a migration means
running its DDL inside one transaction and stamping the new version in
that same transaction, so a database never persists a half-applied
migration: any failure rolls the statements and the version stamp back
together, leaving the file at its prior version.
Version 0 means "the file exists but carries no NestQuest schema yet"
— a fresh file, or a file this integration has never written. Starting
the runner against version 0 applies migration 1, which applies the
full v1 DDL from :mod:`.schema` and stamps version 1.
Migration 1 was rewritten pre-release (D-007): nothing was ever
shipped, so the v1 DDL now creates the ``quest_*`` tables directly
instead of the legacy ``task_*`` spellings, and no compatibility shim
exists. Dev machines that already hold a version-1 database with
``task_*`` tables are handled by migration 2, which detects the legacy
table names and ``ALTER TABLE ... RENAME TO``s them to their current
names before anything else assumes the new model. Migration 3 brings
pre-D-008 databases to the multi-assignee model: it creates
``quest_definition_assignees`` and, when ``quest_definitions`` still
carries a single ``child_id`` column, rebuilds the table without it,
copying each definition's assignee across. Migration 4 adds the
windows table, migration 5 rebuilds ``quest_instances`` onto the
widened (definition_id, child_id, due_date, window) key, and migration
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
column. Migration 7 adds the generic ``nestquest_meta_state``
key/value table for integration-owned bookkeeping (Feature 11's
missed-sweep watermark). Each callable migration runs inside its own
transaction
together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together.
Migration entries are either an ordered sequence of SQL statements or
an async callable taking ``(database, target_version)``. Callables
exist for steps whose SQL depends on the database's current state (a
legacy rename only fires when a legacy table is present; a rebuild only
when the old column shape is present) and own their transaction so they
can bracket it with connection pragmas the runner's uniform transaction
cannot express.
Each migration's statements and its version stamp run inside ONE
transaction, and the stamp is written as DELETE + INSERT rather than
UPDATE because a version-0 database has no version row to update yet.
An UPDATE form would silently match zero rows there and leave the
database migrated but unstamped — reporting version 0 forever, and
re-applying migration 1 on every startup. The DELETE+INSERT shape is
also what makes the stamp a replace, so the row never duplicates.
Idempotence across restarts: a database already at the latest version
has no pending migrations, so the runner opens a read-only check, finds
nothing to do, and leaves the file byte-untouched (no write transaction
is opened, so no WAL churn).
Migration list policy: entries are append-only. Once shipped, a
migration's SQL must never be edited — an old database applying it for
the first time and a fresh database would then diverge. Fix-forward
with a new migration instead. The version stamp lives in the same
SQLite file as the data it describes, so restoring a backup restores
its matching schema version with it.
Corrupt or missing files are not this module's concern: startup
handling of those cases is task d5bca74b, which decides whether to
open fresh or raise ConfigEntryNotReady. This runner assumes it is
handed an open, working connection.
The versioned migration runner lives in
:mod:`custom_components.nestquest.core.migrations`
(extracted in task d0b691d8); this module re-exports it so existing
``from .migrations import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable, Sequence
from typing import Union
from .db import NestQuestDatabase
from .schema import (
COMPLETION_EVENTS_TABLE_SQL,
QUEST_DEFINITIONS_TABLE_SQL,
QUEST_INSTANCES_TABLE_SQL,
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
SCHEMA_V7_META_STATE_DDL,
)
LOGGER = logging.getLogger(__name__)
#: One migration entry: either an ordered sequence of SQL statements or
#: an async callable taking ``(database, target_version)`` that manages
#: its own transaction and stamps via :func:`stamp_schema_version`.
MigrationStep = Union[
Sequence[str],
Callable[[NestQuestDatabase, int], Awaitable[None]],
]
#: Metadata table holding the applied schema version. Prefixed with the
#: integration domain so a config directory shared with other tools can
#: never collide with a foreign ``schema_version`` table.
VERSION_TABLE = "nestquest_schema_version"
#: DDL for the version metadata table. The singleton-row invariant is
#: enforced by the schema itself: ``id`` is fixed at 1 by CHECK, keyed
#: PRIMARY KEY, so the table can never hold a second row, and the
#: stamp write is an UPDATE (no DELETE+INSERT needed). ``version`` is
#: a positive integer: version 0 means "no row" and is never stored,
#: so a negative or zero stored value is corruption, not a state the
#: runner can act on.
VERSION_TABLE_DDL = f"""
CREATE TABLE IF NOT EXISTS {VERSION_TABLE} (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL CHECK (version >= 1)
)
"""
#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades
#: version ``n-1`` to version ``n``.
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
#: The assignees table DDL, shared with migration 1 via the schema
#: module so a rebuilt and a freshly created table cannot drift.
_ASSIGNEES_TABLE_DDL: str = SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL[0]
#: Legacy (pre-D-007) table name -> current table name. Order matters:
#: the parent table renames first so the child's foreign key reference
#: is rewritten while its parent's new name is already in place.
LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = (
("task_definitions", "quest_definitions"),
("task_instances", "quest_instances"),
)
async def stamp_schema_version(
database: NestQuestDatabase, version: int
) -> None:
"""Write the schema version stamp (inside the caller's transaction).
INSERT keyed on the singleton row (id = 1) with an upsert: a fresh
file has no row yet, so UPDATE alone would silently match zero rows
there and leave a migrated-but-unstamped database.
"""
await database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) "
"VALUES (1, ?) "
"ON CONFLICT (id) DO UPDATE SET version = excluded.version",
(version,),
)
async def _table_has_column(
database: NestQuestDatabase, table: str, column: str
) -> bool:
"""Return True when ``table`` currently carries ``column``."""
rows = await database.fetch_all(f"PRAGMA table_info({table})")
return any(row[1] == column for row in rows)
#: Window assigned to instances migrated from the pre-window instance
#: shape (D-008). Pre-window rows were generated for whole-day
#: obligations; the morning window is the documented default landing
#: spot, and future dates are regenerated with real window declarations
#: by materialization (delete_future_uncompleted + re-run).
LEGACY_MIGRATED_WINDOW = "morning"
async def _rename_legacy_task_tables(
database: NestQuestDatabase, target_version: int
) -> None:
"""Rename any surviving ``task_*`` tables to their ``quest_*`` names.
Dev-machines-only path (D-007): a database stamped version 1 by the
pre-rename runner holds ``task_definitions``/``task_instances``.
Each legacy name still present is renamed; fresh databases find
nothing and this step is a no-op. Modern SQLite rewrites foreign
key clauses in other tables to follow the rename, so the renamed
schema is indistinguishable from a freshly created one apart from
internal autoindex names. Runs in its own transaction together
with the version stamp, so a crash rolls both back.
"""
async with database.transaction():
for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES:
row = await database.fetch_one(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name = ?",
(legacy_name,),
)
if row is not None:
await database.execute(
f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"'
)
LOGGER.info(
"Renamed legacy table %s to %s",
legacy_name,
current_name,
)
await stamp_schema_version(database, target_version)
async def _add_definition_assignees(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring definitions to the multi-assignee model (D-008).
Two shapes arrive here:
- Fresh or renamed databases whose ``quest_definitions`` already
matches the current canonical DDL (no ``child_id`` column): only
the assignees table needs creating (``IF NOT EXISTS`` is a no-op
when migration 1 already made it).
- Databases stamped at version 2 whose ``quest_definitions`` still
carries the single ``child_id`` column: the table is rebuilt via
SQLite's canonical procedure — create the new table, copy rows,
move each definition's assignee into
``quest_definition_assignees``, drop the old table, rename — so
``DROP COLUMN``'s foreign-key restriction never applies and no
definition or instance row is lost.
The copy runs with ``foreign_keys = OFF`` because dropping the old
table under enforcement would fire the implicit DELETE and violate
the ``quest_instances`` foreign key; ``PRAGMA foreign_key_check``
runs inside the same transaction before the stamp, so a corrupt
copy aborts the whole migration instead of persisting. The pragma
is a connection-level no-op inside a transaction, hence the
off/transaction/on bracket.
"""
legacy_child_id = await _table_has_column(
database, "quest_definitions", "child_id"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
await database.execute(_ASSIGNEES_TABLE_DDL)
if legacy_child_id:
await database.execute(
"CREATE TABLE quest_definitions_rebuilt "
f"{QUEST_DEFINITIONS_TABLE_SQL}"
)
await database.execute(
"INSERT INTO quest_definitions_rebuilt "
"(id, title, description, icon, schedule_rule_id, "
"due_time, is_active, created_at) "
"SELECT id, title, description, icon, "
"schedule_rule_id, due_time, is_active, created_at "
"FROM quest_definitions ORDER BY id"
)
await database.execute(
"INSERT OR IGNORE INTO quest_definition_assignees "
"(definition_id, child_id) "
"SELECT id, child_id FROM quest_definitions"
)
await database.execute("DROP TABLE quest_definitions")
await database.execute(
"ALTER TABLE quest_definitions_rebuilt "
"RENAME TO quest_definitions"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"definition rebuild produced foreign-key "
f"violations: {violations!r}"
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
async def _widen_instance_key(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring ``quest_instances`` to the D-008 instance key.
Databases whose instance table still carries the pre-window shape
(no ``window`` column, UNIQUE on (definition_id, due_date)) are
rebuilt via SQLite's canonical procedure into the widened shape —
UNIQUE (definition_id, child_id, due_date, window) — with every
migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing
ids (and the completion events referencing them) survive intact.
The old rows were unique per (definition, due_date), so pinning one
window cannot collide with the widened key. Fresh databases whose
table already matches the canonical shape only stamp.
The rebuild runs with ``foreign_keys = OFF`` (dropping the old
table under enforcement would fire the implicit DELETE and violate
the ``completion_events`` foreign key) and verifies
``PRAGMA foreign_key_check`` inside the transaction before the
stamp, so a corrupt copy aborts the whole migration.
"""
legacy_shape = not await _table_has_column(
database, "quest_instances", "window"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
if legacy_shape:
await database.execute(
"CREATE TABLE quest_instances_rebuilt "
f"{QUEST_INSTANCES_TABLE_SQL}"
)
await database.execute(
"INSERT INTO quest_instances_rebuilt "
"(id, definition_id, child_id, window, due_date, "
"due_time, generated_at) "
"SELECT id, definition_id, child_id, ?, due_date, "
"due_time, generated_at "
"FROM quest_instances ORDER BY id",
(LEGACY_MIGRATED_WINDOW,),
)
await database.execute("DROP TABLE quest_instances")
await database.execute(
"ALTER TABLE quest_instances_rebuilt "
"RENAME TO quest_instances"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"instance rebuild produced foreign-key "
f"violations: {violations!r}"
)
LOGGER.info(
"Widened quest_instances key; migrated rows pinned "
"to the %r window",
LEGACY_MIGRATED_WINDOW,
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
async def _add_actor_child_id(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring ``completion_events`` to the D-008 actor-child shape.
The ``actor_child_id`` column records which panel profile was
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
because the canonical shape also carries an actor-pair coherence
CHECK (panel events require it, user events forbid it), and CHECK
constraints cannot be added to an existing table. Databases with
the legacy shape are therefore rebuilt via SQLite's canonical
procedure, backfilling each legacy panel row's ``actor_child_id``
to ``child_id`` — pre-D-008 panel completions were always recorded
against the tapped child themselves — while admin rows stay NULL.
Row ids are preserved so ``quest_instances`` references and the
append-only history stay intact. Fresh databases already match
the canonical shape and only stamp.
The rebuild runs with ``foreign_keys = OFF`` and verifies
``PRAGMA foreign_key_check`` inside the transaction before the
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
completion_events table keeps its append-only guarantee.
"""
legacy_shape = not await _table_has_column(
database, "completion_events", "actor_child_id"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
if legacy_shape:
await database.execute(
"CREATE TABLE completion_events_rebuilt "
f"{COMPLETION_EVENTS_TABLE_SQL}"
)
await database.execute(
"INSERT INTO completion_events_rebuilt "
"(id, instance_id, child_id, event_type, actor_source, "
"actor_user_id, actor_child_id, occurred_at, "
"was_on_time) "
"SELECT id, instance_id, child_id, event_type, "
"actor_source, actor_user_id, "
"CASE WHEN actor_source = 'panel' THEN child_id "
"ELSE NULL END, "
"occurred_at, was_on_time "
"FROM completion_events ORDER BY id"
)
await database.execute("DROP TABLE completion_events")
await database.execute(
"ALTER TABLE completion_events_rebuilt "
"RENAME TO completion_events"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"completion_events rebuild produced foreign-key "
f"violations: {violations!r}"
)
LOGGER.info(
"Added actor_child_id to completion_events; legacy "
"panel rows backfilled to their own child_id"
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
#: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead. (Migration 1's content was
#: rewritten pre-release per D-007 — no version 1 database shipped.)
MIGRATIONS: Sequence[MigrationStep] = [
MIGRATION_1_V1_DDL,
_rename_legacy_task_tables,
_add_definition_assignees,
# Migration 4 (D-008): the windows table is a plain new table — a
# CREATE IF NOT EXISTS suffices for both fresh databases (which
# already made it in migration 1) and pre-window databases.
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
# Migration 5 (D-008): rebuild quest_instances onto the widened
# (definition_id, child_id, due_date, window) key.
_widen_instance_key,
# Migration 6 (D-008): rebuild completion_events with the
# actor_child_id column and its actor-pair coherence CHECK,
# backfilling legacy panel rows to their own child_id.
_add_actor_child_id,
# Migration 7 (Feature 11): the generic meta_state key/value table
# — a plain new table, so CREATE IF NOT EXISTS suffices for both
# fresh databases (which already made it in migration 1) and
# pre-v7 databases. Strictly additive; no existing table changes.
list(SCHEMA_V7_META_STATE_DDL),
]
async def _create_version_table(database: NestQuestDatabase) -> None:
"""Create the version metadata table if it does not exist.
The table is created before any version check: it is the runner's
own bookkeeping, and creating it is idempotent. The DDL enforces
the singleton-row invariant (``id`` fixed at 1) and a positive
integer version at the database level.
"""
await database.execute(VERSION_TABLE_DDL)
async def read_schema_version(database: NestQuestDatabase) -> int:
"""Return the schema version recorded in the database.
Returns 0 when the version table or its row is missing — the
"fresh file" case. A stored version higher than the runner knows
is surfaced as-is; the caller decides how to treat a future version.
Reading is deliberately passive: a missing table raises, it is not
swallowed into 0, because :func:`apply_migrations` always creates
the table before reading and a direct reader hitting "no such
table" is a caller bug worth surfacing, not a fresh file.
"""
row = await database.fetch_one(
f"SELECT version FROM {VERSION_TABLE} LIMIT 1"
)
if row is None:
return 0
version = row[0]
if not isinstance(version, int) or isinstance(version, bool):
raise RuntimeError(
f"Schema version metadata is not an integer: {version!r}"
)
if version < 1:
raise RuntimeError(
f"Schema version metadata is invalid (must be >= 1): {version!r}"
)
return version
#: One asyncio.Lock per connection wrapper, created lazily and bound to
#: the running loop, serializing whole apply_migrations runs so two
#: concurrent callers on one connection queue instead of colliding on
#: the wrapper's single-transaction rule.
_MIGRATION_LOCKS: dict[int, asyncio.Lock] = {}
def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
"""Return the migration lock bound to ``database``'s running loop."""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _MIGRATION_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_MIGRATION_LOCKS[key] = lock
return lock
async def apply_migrations(
database: NestQuestDatabase,
migrations: Sequence[MigrationStep] = MIGRATIONS,
) -> int:
"""Bring the database schema up to the latest version.
Reads the current version, applies every pending migration in order
— each inside one transaction that also stamps the new version —
and returns the resulting version. Applying zero pending
migrations (database already current) is a no-op that logs at debug
level and opens no write transaction.
The version is re-read inside each migration transaction before
deciding to apply it: the outer read is advisory only (loop bounds,
no-op fast path), and on one shared connection two interleaved
runners would otherwise both read the same version and both apply
a non-idempotent migration. Under the transaction's lock the
second runner sees the first runner's stamp and skips.
Runners on one connection are serialized by an asyncio lock held
for the whole decide-and-apply span: NestQuestDatabase rejects a
second transaction while one is active (RuntimeError), so
concurrent callers must queue, and the lock makes that queueing
explicit. The loser of the race re-reads the version once it
acquires the lock, sees the winner's stamp, and applies nothing.
Raises whatever the failing migration raised, with the database
rolled back to its prior version and prior rows untouched.
"""
async with _migration_lock(database):
await _create_version_table(database)
current = await read_schema_version(database)
latest = len(migrations)
if current > latest:
raise RuntimeError(
f"Database schema version {current} is newer than this "
f"integration understands (latest known: {latest}); "
"downgrades are not supported"
)
if current == latest:
LOGGER.debug(
"NestQuest schema already at version %d; "
"no migrations to apply",
current,
)
return current
for target in range(current + 1, latest + 1):
applied = False
step = migrations[target - 1]
if callable(step):
# Callable migrations own their transaction: some steps
# (table rebuilds) must run connection pragmas such as
# ``foreign_keys = OFF`` BEFORE opening one, which the
# runner's uniform transaction cannot express, and they
# stamp inside their own transaction so a failure rolls
# the statements and the version stamp back together.
# The version is re-read here under the migration lock:
# runners on one connection are serialized by it, so
# the version cannot change between this read and the
# callable's commit.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
f"Database schema version {in_tx_version} is newer "
f"than this integration understands (latest known: "
f"{latest}); downgrades are not supported"
)
if in_tx_version < target:
await step(database, target)
applied = True
else:
async with database.transaction():
# Re-read the version INSIDE the write transaction:
# the outer read was advisory, and the wrapper
# serializes individual statements, not the
# read-decide-apply decision. Under the
# transaction's lock the version cannot change
# between the read and the stamp, and the loser of
# an interleaved race (a queued second caller
# re-running apply_migrations after the winner
# already migrated) re-reads here, sees the
# winner's stamp, and skips instead of re-applying
# a non-idempotent migration with a stale version.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
f"Database schema version {in_tx_version} is "
f"newer than this integration understands "
f"(latest known: {latest}); downgrades are "
f"not supported"
)
if in_tx_version < target:
for sql in step:
await database.execute(sql)
await stamp_schema_version(database, target)
applied = True
applied_statements = len(step)
if applied:
LOGGER.info(
"NestQuest schema migrated to version %d",
target,
)
return latest
from .core.migrations import * # noqa: F401,F403
+2 -4
View File
@@ -1,7 +1,6 @@
"""Options flow for NestQuest post-setup settings."""
from __future__ import annotations
import re
from typing import Any
import voluptuous as vol
@@ -33,18 +32,17 @@ from .const import (
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
MIN_UPDATE_INTERVAL,
TIME_PATTERN,
)
from .admin_allowlist import list_admin_ids, set_admin_ids
MIN_HORIZON_DAYS = 1
MIN_PANEL_IDLE_TIMEOUT = 30
_TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
def _parse_time(value: str) -> bool:
"""Return True when value is a valid 24-hour HH:MM string."""
return _TIME_PATTERN.fullmatch(value) is not None
return TIME_PATTERN.fullmatch(value) is not None
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
+7 -473
View File
@@ -1,477 +1,11 @@
"""The presence and custody model (Feature 05).
"""Re-export of :mod:`custom_components.nestquest.core.presence` for the integration package.
PURE module: no Home Assistant imports, no database access, no
timezone logic — it operates on plain calendar dates only (feature
guardrail). The later engine (cycle position, ``is_present``,
overrides, previews) builds directly on this model.
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
week is this child at this house?" for an N-week repeating custody
pattern. Two of the household's three children follow a two-week
rotation; a child with NO schedule row is present every day (that
default lives in the engine, not here).
Pattern encoding: the model's canonical serialization is the SAME
pipe-separated CSV the database stores (see schema.py's
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
empty segment means absent every day of that week. Encoding sorts
each segment's weekdays and uses the exact segment order, so
``decode(encode(schedule))`` round-trips losslessly and two schedules
with equal patterns produce equal strings.
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
computed from anchor-date arithmetic ONLY — never ISO week numbers or
week parity (D-004): 53-week years silently invert parity on January
1st. The arithmetic lives in the engine task; this module validates
that the anchor is a strict calendar date.
Validation policy: construction is total — every reject happens in
``__init__`` so no invalid schedule can ever exist:
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
the database CHECK (schema.py) and keeps anchor arithmetic and the
encoded pattern human-readable; revisit both together if a longer
cycle is ever needed.
- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly
(no gaps, no extra weeks) with sets of integers 0..6 (bools
rejected: True would silently mean Monday).
- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a
``datetime.date``).
The presence and custody model lives in
:mod:`custom_components.nestquest.core.presence` (extracted in
task 510c1f78); this module re-exports it so existing
``from .presence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from types import MappingProxyType
#: The schema's documented cap for cycle_length_weeks (schema.py keeps
#: the pattern human-readable and the anchor arithmetic small).
MAX_CYCLE_LENGTH_WEEKS = 4
_DATE_FORMAT = "%Y-%m-%d"
def _parse_date(value: object, field_name: str) -> datetime.date:
"""Accept a datetime.date or a strict YYYY-MM-DD string.
``datetime.datetime`` is rejected explicitly: it SUBCLASSES
datetime.date, so a bare isinstance check would store a value with
a time component and every later calendar-date comparison (e.g.
``PresenceOverride.covers``) would raise TypeError.
"""
if isinstance(value, datetime.datetime):
raise ValueError(
f"{field_name} must be a plain calendar date, not a "
f"datetime with a time component: {value!r}"
)
if isinstance(value, datetime.date):
return value
if not isinstance(value, str):
raise ValueError(
f"{field_name} must be a datetime.date or an ISO date string, "
f"got {value!r}"
)
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise ValueError(
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise ValueError(
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
)
return parsed
def _validate_week_index(week: object, cycle_length_weeks: int) -> int:
"""Reject non-int week indices (bools included) and out-of-cycle."""
if isinstance(week, bool) or not isinstance(week, int):
raise ValueError(
f"pattern week index must be an integer, got {week!r}"
)
if not 0 <= week < cycle_length_weeks:
raise ValueError(
f"pattern week index must be in 0..{cycle_length_weeks - 1}, "
f"got {week}"
)
return week
def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
"""Validate one week's present-weekday set."""
if isinstance(weekdays, (str, bytes)) or weekdays is None:
raise ValueError(
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
f"got {weekdays!r}"
)
try:
members = list(weekdays)
except TypeError:
raise ValueError(
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
f"got {weekdays!r}"
) from None
for member in members:
if isinstance(member, bool) or not isinstance(member, int):
raise ValueError(
f"pattern[{week}] weekday must be an int 0..6, got {member!r}"
)
if not 0 <= member <= 6:
raise ValueError(
f"pattern[{week}] weekday must be in 0..6 (Monday=0), "
f"got {member}"
)
return frozenset(members)
def _validate_cycle_length(value: object) -> int:
"""Reject non-int and out-of-range cycle lengths (bools included)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(
f"cycle_length_weeks must be an integer, got {value!r}"
)
if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS:
raise ValueError(
f"cycle_length_weeks must be between 1 and "
f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}"
)
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools would silently address child 1)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
@dataclass(frozen=True, init=False)
class PresenceSchedule:
"""One child's N-week repeating presence pattern.
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
to the set of present weekdays (ints, Monday=0). A week with an
empty set means the child is absent every day of that week — which
is distinct from having no schedule at all (the engine treats a
missing schedule as present every day).
"""
child_id: int
cycle_length_weeks: int
anchor_date: datetime.date
pattern: MappingProxyType # week index -> frozenset of weekdays
def __init__(
self,
child_id: int,
cycle_length_weeks: int,
anchor_date: object,
pattern: dict[int, object],
) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id))
cycle = _validate_cycle_length(cycle_length_weeks)
object.__setattr__(self, "cycle_length_weeks", cycle)
object.__setattr__(
self, "anchor_date", _parse_date(anchor_date, "anchor_date")
)
if not isinstance(pattern, dict):
raise ValueError(
"pattern must map week index to a set of present "
f"weekdays, got {pattern!r}"
)
normalised: dict[int, frozenset[int]] = {}
for week, weekdays in pattern.items():
index = _validate_week_index(week, cycle)
normalised[index] = _validate_weekday_set(index, weekdays)
missing = [
week for week in range(cycle) if week not in normalised
]
if missing:
raise ValueError(
f"pattern must cover week indices 0..{cycle - 1}; "
f"missing {missing}"
)
# Frozen must mean frozen: a bare dict would let callers inject
# unvalidated weekdays (or drop required weeks) AFTER
# construction, bypassing total validation. The mapping
# proxy exposes the validated contents read-only; the
# per-week sets are already frozensets.
object.__setattr__(self, "pattern", MappingProxyType(normalised))
def encode(self) -> str:
"""Serialize to the database's pipe-separated CSV shape.
Segments appear in week order; each segment is the week's
present weekdays as a sorted comma-separated list (an empty set
encodes as an empty segment). Two schedules with equal
patterns encode identically.
"""
segments = [
",".join(str(day) for day in sorted(self.pattern[week]))
for week in range(self.cycle_length_weeks)
]
return "|".join(segments)
@classmethod
def decode(
cls,
child_id: int,
anchor_date: object,
encoded: str,
) -> "PresenceSchedule":
"""Build a schedule from its encoded pattern.
The cycle length is derived from the segment count, mirroring
the database's segment-count CHECK. Decoding is strict: wrong
shapes, non-numeric or out-of-range weekdays are rejected —
the database CHECKs make such rows unrepresentable, and this
is the same contract on the model side. A segment count above
the documented cycle cap is rejected by the ordinary
construction validation.
"""
if not isinstance(encoded, str):
raise ValueError(
f"encoded pattern must be a string, got {encoded!r}"
)
segments = encoded.split("|")
pattern: dict[int, set[int]] = {}
for week, segment in enumerate(segments):
weekdays: set[int] = set()
if segment:
for part in segment.split(","):
if len(part) != 1 or part not in "0123456":
raise ValueError(
f"encoded segment {week} carries an invalid "
f"weekday {part!r} (single digits 0-6 only)"
)
weekdays.add(int(part))
pattern[week] = weekdays
return cls(child_id, len(segments), anchor_date, pattern)
@dataclass(frozen=True, init=False)
class PresenceOverride:
"""A date-specific present/absent flag beating any pattern.
Covers a single date (``end_date == start_date``) or an inclusive
range. A concrete range is the storage shape
(``presence_overrides.end_date`` is NOT NULL), so the model
requires both bounds and validates start <= end. Construction and
validation live here so the engine can trust every instance.
"""
child_id: int
start_date: datetime.date
end_date: datetime.date
is_present: bool
note: str | None
def __init__(
self,
child_id: int,
start_date: object,
end_date: object,
is_present: bool,
note: str | None = None,
) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id))
start = _parse_date(start_date, "start_date")
end = _parse_date(end_date, "end_date")
if end < start:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end.isoformat()} < {start.isoformat()}"
)
object.__setattr__(self, "start_date", start)
object.__setattr__(self, "end_date", end)
if not isinstance(is_present, bool):
raise ValueError(
f"is_present must be a real bool, got {is_present!r}"
)
object.__setattr__(self, "is_present", is_present)
if note is not None and not isinstance(note, str):
raise ValueError(f"note must be a string or None, got {note!r}")
object.__setattr__(self, "note", note)
def covers(self, date: datetime.date) -> bool:
"""Return True when the override applies to ``date``."""
return self.start_date <= date <= self.end_date
def cycle_week_index(
schedule: PresenceSchedule, target_date: object
) -> int:
"""Return the cycle week index ``target_date`` falls in.
Anchor-date arithmetic ONLY (D-004): ``((target_date -
anchor_date).days // 7) % cycle_length_weeks``. Python's floor
division makes dates BEFORE the anchor behave correctly — they
walk the cycle backwards (day -1 is the last week of the cycle,
day -8 wraps one further) instead of inverting, which is exactly
what ISO week parity does wrong in 53-week years. The modulo of
a negative in Python is non-negative, so the index is always a
valid week index.
"""
target = _parse_date(target_date, "target_date")
days = (target - schedule.anchor_date).days
return (days // 7) % schedule.cycle_length_weeks
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
#: years of daily checks. A child absent every day (an all-absent
#: pattern with a blocking override) would otherwise loop forever; the
#: cap bounds the scan and surfaces the situation as an error instead.
MAX_PREVIEW_SCAN_DAYS = 366 * 2
class PresenceEngine:
"""Answers "is this child at this house on this date?".
Pure and side-effect-free: built from an immutable snapshot of the
children's schedules (``child_id -> PresenceSchedule``), it never
touches the database — callers build a snapshot from the DAO layer
and pass it in. A child with NO schedule entry is present every
day (the third household child's case); a child WITH a schedule is
present exactly when the date's weekday is in that schedule's
pattern for the date's cycle week index (anchor-date arithmetic —
:func:`cycle_week_index`, never ISO week parity).
"""
def __init__(
self,
schedules: dict[int, PresenceSchedule],
overrides: dict[int, list[PresenceOverride]] | None = None,
) -> None:
if not isinstance(schedules, dict):
raise ValueError(
f"schedules must map child_id to PresenceSchedule, got "
f"{schedules!r}"
)
for child_id, schedule in schedules.items():
# Validate the KEY as a model child id too: True and 1.0
# compare equal to 1, so {True: schedule(1)} would be
# accepted and then behave differently on lookup.
_validate_child_id(child_id)
if not isinstance(schedule, PresenceSchedule):
raise ValueError(
f"schedules[{child_id!r}] must be a PresenceSchedule, "
f"got {schedule!r}"
)
if schedule.child_id != child_id:
raise ValueError(
f"schedules is keyed {child_id!r} but holds the "
f"schedule of child {schedule.child_id}"
)
if overrides is None:
overrides = {}
if not isinstance(overrides, dict):
raise ValueError(
"overrides must map child_id to a list of "
f"PresenceOverride, got {overrides!r}"
)
for child_id, entries in overrides.items():
_validate_child_id(child_id)
if not isinstance(entries, list):
raise ValueError(
f"overrides[{child_id!r}] must be a list of "
f"PresenceOverride, got {entries!r}"
)
for entry in entries:
if not isinstance(entry, PresenceOverride):
raise ValueError(
f"overrides[{child_id!r}] entries must be "
f"PresenceOverride, got {entry!r}"
)
if entry.child_id != child_id:
raise ValueError(
f"overrides is keyed {child_id!r} but holds an "
f"override of child {entry.child_id}"
)
# Read-only snapshot: reassignment is refused and no mutator
# exists, so a built engine answers consistently for its
# lifetime. The overrides lists are copied to immutable
# tuples — a shallow mapping copy alone would leave caller-
# owned lists reachable through _overrides, and mutating one
# later would change is_present results.
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
object.__setattr__(
self,
"_overrides",
MappingProxyType(
{child_id: tuple(entries) for child_id, entries in overrides.items()}
),
)
def is_present(self, child_id: object, date: object) -> bool:
"""Return whether ``child_id`` is present on ``date``.
Overrides take precedence over the repeating pattern: when an
override covers the date, its flag IS the answer — including
for a child with no schedule at all. The DAO layer guarantees
a child's overrides never overlap; if a snapshot still carries
two covering the same date, the one with the LATEST start date
wins (deterministic, and matches "the most specific swap wins").
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
target = _parse_date(date, "date")
entries = self._overrides.get(child_id) or []
covering = [
entry for entry in entries if entry.covers(target)
]
if covering:
winner = max(covering, key=lambda entry: entry.start_date)
return winner.is_present
schedule = self._schedules.get(child_id)
if schedule is None:
# No schedule at all: present every day (household default).
return True
week = cycle_week_index(schedule, target)
return target.weekday() in schedule.pattern[week]
def next_present_dates(
self,
child_id: object,
start_date: object,
count: int,
) -> list[datetime.date]:
"""Return the next ``count`` dates the child is present.
Walks forward from ``start_date`` (INCLUSIVE — a parent
previewing "starting Monday" expects Monday itself when the
child is present) checking :meth:`is_present` for every day,
so overrides and the pattern are honoured identically. The
admin Schedule tab renders this preview so a parent can confirm
a custody pattern is right before saving it.
A child absent every scanned day raises ValueError after the
two-year search cap — an always-absent child would otherwise
loop forever. ``count`` must be a positive integer.
"""
_validate_child_id(child_id)
start = _parse_date(start_date, "start_date")
if isinstance(count, bool) or not isinstance(count, int):
raise ValueError(f"count must be an integer, got {count!r}")
if count < 1:
raise ValueError(f"count must be at least 1, got {count}")
found: list[datetime.date] = []
day = start
for _ in range(MAX_PREVIEW_SCAN_DAYS):
if self.is_present(child_id, day):
found.append(day)
if len(found) == count:
return found
day += datetime.timedelta(days=1)
raise ValueError(
f"child {child_id} is not present on any of the "
f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; "
f"{count} present day(s) cannot be previewed"
)
def __setattr__(self, name: object, value: object) -> None:
raise AttributeError(
"PresenceEngine is an immutable snapshot; build a new engine "
"instead of mutating this one"
)
from .core.presence import * # noqa: F401,F403
@@ -1,603 +1,11 @@
"""Business layer for creating and editing quest definitions (Feature 06).
"""Re-export of :mod:`custom_components.nestquest.core.quest_definitions` for the integration package.
Sits between the Feature 09 service gate and the typed
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path,
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
Validation policy mirrors :mod:`.children`:
- ``title`` is required and trimmed; empty or whitespace-only raises
ValueError.
- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
(its constructor is total — rule validation is NOT re-implemented
here).
- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``);
every id is a plain int (bools and floats rejected, since SQLite would
bind them onto a real child) and must reference an existing ACTIVE
child (the DAO enforces the active/exists check inside the same
transaction). Duplicate ids are rejected here, before the composite
primary key could surface them as an :class:`sqlite3.IntegrityError`.
- ``windows`` must be a non-empty list (strictly a ``list``); each entry
is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare
name (no due time) or a ``(name, due_time)`` pair whose due time is a
strict 24-hour HH:MM string. Duplicate window names are rejected
here, before the composite primary key could surface them as an
:class:`sqlite3.IntegrityError`.
The rule, definition, assignees and windows are persisted in ONE
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
so a rejected or failed create persists nothing. The definition-level
``due_time`` column is superseded by per-window due times (D-008) and is
never set here. ``created_at`` is stamped UTC ISO-8601 at second
precision, matching :mod:`.children`.
The quest definitions business layer lives in
:mod:`custom_components.nestquest.core.quest_definitions` (extracted in
task 510c1f78); this module re-exports it so existing
``from .quest_definitions import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import ChildRecord, _connection_lock
from .dao_rules import (
QuestDefinitionRecord,
QuestDefinitionSnapshot,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
ScheduleRuleStorage,
ScheduleRulesDao,
_UNSET,
_validate_date,
_validate_time,
_validate_window,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
from .materialize import regenerate_for_definition
from .recurrence import ScheduleRule, occurs_on
@dataclass(frozen=True)
class CreatedQuestDefinition:
"""The persisted result of a create, edit or activation change.
``definition`` is the stored row; ``rule`` is the schedule rule
decoded back through the storage mapping; ``assignees`` and
``windows`` are the definition's linked child records and window
declarations, read through the DAO.
"""
definition: QuestDefinitionRecord
rule: ScheduleRule
assignees: list[ChildRecord]
windows: list[QuestDefinitionWindowRecord]
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_text(
value: object, field: str, *, required: bool = False
) -> str | None:
"""Validate a free-text field and return it trimmed.
``None`` raises when ``required`` and otherwise returns None. A
non-string value is rejected; an empty result is rejected for
required fields.
"""
if value is None:
if required:
raise ValueError(f"{field} is required")
return None
if not isinstance(value, str):
raise ValueError(f"{field} must be a string, got {value!r}")
trimmed = value.strip()
if required and not trimmed:
raise ValueError(f"{field} is required")
return trimmed
def _validate_assignee_ids(value: object) -> list[int]:
"""Return the assignee ids as plain ints, or raise naming the field.
The argument must be a non-empty list — strictly a ``list``: tuples
and other sequences are rejected. Each entry must be a real int
(bools and floats rejected) so SQLite cannot bind ``True`` onto
child 1 or a float onto a neighbouring profile.
"""
if type(value) is not list:
raise ValueError(
f"assignee_child_ids must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("assignee_child_ids must not be empty")
ids: list[int] = []
for entry in value:
if isinstance(entry, bool) or not isinstance(entry, int):
raise ValueError(
"assignee_child_ids must contain integer child ids, "
f"got {entry!r}"
)
ids.append(entry)
if len(set(ids)) != len(ids):
raise ValueError(
"assignee_child_ids must not contain duplicate child ids"
)
return ids
def _normalize_windows(
value: object,
) -> list[tuple[str, str | None]]:
"""Normalize the windows argument to ``(name, due_time)`` pairs.
The outer argument must be a non-empty list — strictly a ``list``:
tuples and other sequences are rejected, while each individual entry
may still be a ``(window, due_time)`` tuple. A bare window name
(due time None) is also accepted. The name must be a
:data:`~.const.QUEST_WINDOWS` spelling and a present due time must be
a strict 24-hour HH:MM, both validated up front so a malformed
window is rejected before any write.
"""
if type(value) is not list:
raise ValueError(
f"windows must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("windows must not be empty")
normalized: list[tuple[str, str | None]] = []
seen: set[str] = set()
for index, entry in enumerate(value):
if isinstance(entry, str):
name: object = entry
due_time: object = None
elif isinstance(entry, (tuple, list)) and len(entry) == 2:
name, due_time = entry[0], entry[1]
else:
raise ValueError(
f"windows[{index}] must be a window name or a "
f"(name, due_time) pair, got {entry!r}"
)
_validate_window(name)
if due_time is not None:
_validate_time(due_time, "due_time")
if name in seen:
raise ValueError(
f"windows must not contain duplicate window {name!r}"
)
seen.add(name)
normalized.append((name, due_time))
return normalized
def _validate_definition_id(value: object) -> int:
"""Reject non-int definition ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address definition 1 and a float would round — malformed service
input must never mutate the wrong definition.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"definition_id must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def create_quest_definition(
database: NestQuestDatabase,
title: str,
rule: ScheduleRule,
assignee_child_ids: list[int],
windows: list[str | tuple[str, str | None]],
*,
description: str | None = None,
icon: str | None = None,
) -> CreatedQuestDefinition:
"""Create a quest definition (rule + assignees + windows) atomically.
Validates the arguments up front, then persists the schedule rule,
the definition, its assignees and its windows in one transaction via
the DAO. Returns the stored definition together with its decoded
rule, assignees and windows — all read back inside that same
transaction, so the returned bundle is a consistent creation
snapshot. Raises ValueError on any rejected argument; a rejected or
failed create persists nothing.
"""
name = _validate_text(title, "title", required=True)
assert name is not None
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
assignee_ids = _validate_assignee_ids(assignee_child_ids)
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
storage = schedule_rule_to_storage(rule)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.create_with_rule_and_windows(
name,
storage,
_now_stamp(),
assignee_ids,
window_specs,
description=description_value,
icon=icon_value,
)
except ValueError as error:
# The DAO's assignee check names only the child; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(
f"assignee_child_ids: {message}"
) from error
raise
decoded_rule = schedule_rule_from_storage(storage)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def edit_quest_definition(
database: NestQuestDatabase,
definition_id: int,
*,
title: str | object = _UNSET,
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule and windows atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
(required and trimmed); ``description`` and ``icon`` are optional
and may be cleared by passing ``None`` explicitly. A provided
``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
and REPLACES the definition's schedule rule IN PLACE — the existing
``schedule_rules`` row is updated, never orphaned or duplicated. A
provided ``windows`` list REPLACES the whole window set (each entry
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
so the returned bundle is a consistent edit snapshot. Edits change
future instances only; no ``quest_instances`` or
``completion_events`` row is touched.
``today`` optionally pins the caller-resolved HA-local date the
regeneration's no-past guard and horizon are anchored to (threaded
into :func:`~.materialize.regenerate_for_definition`); it is a plain
``datetime.date`` with no HA import here — the Feature 09 service
supplies the hass-derived value. When omitted, regeneration falls
back to the host clock, same as before. ``horizon_days`` sizes the
regeneration's re-materialization window the same way (threaded into
:func:`~.materialize.regenerate_for_definition`); it defaults to
:data:`~.const.DEFAULT_HORIZON_DAYS` when omitted.
"""
_validate_definition_id(definition_id)
title_value: str | None | object = _UNSET
if title is not _UNSET:
name = _validate_text(title, "title", required=True)
assert name is not None
title_value = name
description_value: str | None | object = _UNSET
if description is not _UNSET:
description_value = _validate_text(description, "description")
icon_value: str | None | object = _UNSET
if icon is not _UNSET:
icon_value = _validate_text(icon, "icon")
rule_storage: ScheduleRuleStorage | object = _UNSET
if rule is not _UNSET:
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
rule_storage = schedule_rule_to_storage(rule)
window_specs: list[tuple[str, str | None]] | object = _UNSET
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.edit_definition(
definition_id,
title=title_value,
description=description_value,
icon=icon_value,
rule=rule_storage,
windows=window_specs,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def assign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> list[ChildRecord]:
"""Assign ``child_id`` to the definition (idempotent).
Validates that both ids are plain ints (bools and floats rejected)
and that the child exists and is active — the DAO enforces the
active/exists check inside its transaction, and also confirms the
definition exists. Assigning an already-assigned child is a no-op
(no duplicate row). Returns the definition's assignees read back
inside the DAO's same transaction, a consistent post-assignment
roster (a concurrent assign/unassign cannot race it after the
write). Raises ValueError on any rejected argument. Only the
``quest_definition_assignees`` link is written; existing instances
and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the assignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
try:
assignees = await dao.add_assignee(definition_id, child_id)
except ValueError as error:
# The DAO names only the child/definition; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(f"child_id: {message}") from error
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return assignees
async def unassign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> bool:
"""Unassign ``child_id`` from the definition; True when removed.
Validates both ids are plain ints (bools and floats rejected) and
that the definition exists, then removes the (definition, child)
link. Returns True when a row was removed and False when the child
was not currently assigned. Raises ValueError on a rejected
argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the unassignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
definition = await dao.get(definition_id)
if definition is None:
raise ValueError(
f"definition_id: quest definition {definition_id} does not exist"
)
removed = await dao.remove_assignee(definition_id, child_id)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return removed
async def set_quest_definition_active(
database: NestQuestDatabase,
definition_id: int,
is_active: bool,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Deactivate or reactivate a definition; returns the updated view.
Flipping the flag is the only removal path (Feature 06 guardrail):
a deactivated definition stops future instance generation but its
row, schedule rule, assignees and windows all survive, and existing
instances plus completion history are never touched. Reactivation
re-enables future generation; generating or removing instances is
Feature 07 and does not happen here.
``definition_id`` must be a plain int (bools and floats rejected,
since SQLite would bind ``True`` onto definition 1) and
``is_active`` must be a real bool — the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError naming the
offending field when the id is malformed, ``is_active`` is not a
bool, or the definition does not exist.
Atomicity: the existence check, the UPDATE and the readback of the
definition, its rule, assignees and windows all run inside the
connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race.
``today`` optionally pins the caller-resolved HA-local date threaded
into the activation change's regeneration (see
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
regeneration's re-materialization window the same way.
"""
_validate_definition_id(definition_id)
if not isinstance(is_active, bool):
raise ValueError(f"is_active must be a real bool, got {is_active!r}")
async with _connection_lock(database):
dao = QuestDefinitionsDao(database)
if await dao.get(definition_id) is None:
raise ValueError(
f"definition_id: quest definition {definition_id} "
"does not exist"
)
await dao.set_active(definition_id, is_active)
updated = await dao.get(definition_id)
assert updated is not None
rule_record = await ScheduleRulesDao(database).get(
updated.schedule_rule_id
)
assert rule_record is not None
assignees = await dao.list_assignees(definition_id)
windows = await dao.list_windows(definition_id)
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(rule_record)
)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return CreatedQuestDefinition(
definition=updated,
rule=rule,
assignees=assignees,
windows=windows,
)
def _bundle_snapshot(
snapshot: QuestDefinitionSnapshot,
) -> CreatedQuestDefinition:
"""Decode a DAO snapshot into the layer's rich view.
The rule record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
coherence the DAO's locked snapshot guarantees: the rule, assignees
and windows all come from one atomic read, never three separate ones.
"""
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def list_active_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return every ACTIVE definition with its decoded rule and links.
Ordered by rising definition id, matching the DAO's ``list_active``
order, so callers get a stable, repeatable sequence. Each entry is
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
the layer: the stored row plus its decoded
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
definitions and their links are read by the DAO inside one locked
transaction, so each bundle is a coherent snapshot that a concurrent
edit cannot interleave.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
) -> list[CreatedQuestDefinition]:
"""Return the definitions assigned to ``child_id`` (rich view).
``child_id`` must be a plain int (bools and floats rejected, since
SQLite would bind ``True`` onto child 1). Results are ordered by
rising definition id, matching ``list_active_definitions`` and
``list_definitions_firing_on``. Each entry is a
:class:`CreatedQuestDefinition` snapshot bundling the definition with
its decoded rule, assignees and windows, read atomically by the DAO.
"""
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_by_child(child_id)
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return sorted(bundles, key=lambda bundle: bundle.definition.id)
async def list_definitions_firing_on(
database: NestQuestDatabase,
date: str,
) -> list[CreatedQuestDefinition]:
"""Return the ACTIVE definitions whose rule fires on ``date``.
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
value raises ValueError naming the field. Each active definition's
stored rule is decoded through the task-1 storage mapping and handed
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
definitions whose decoded rule fires on the date are returned, in
rising definition-id order, each as the same rich
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
The definitions and their rules are read inside one locked
transaction, so a bundle can never filter on one rule while
presenting another state's windows.
"""
_validate_date(date, "date")
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
from .core.quest_definitions import * # noqa: F401,F403
+6 -617
View File
@@ -1,621 +1,10 @@
"""The ScheduleRule model: rule types, validation, and serialization.
"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package.
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
frozen dataclass with no Home Assistant and no database imports. The
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
serializes its dict form into the ``schedule_rules`` table.
Type model: one enum member per recurrence shape. ``MONTHLY_DAY``
("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second
Tuesday of every month") are distinct types rather than one monthly
type with optional fields, so validation is total: a rule either
constructs, or raises :class:`RuleValidationError` naming the offending
field. Every type carries ONLY the fields its shape needs; carrying
any other shape's field is a construction error.
Storage mapping (Feature 02, ``schedule_rules`` table): the enum's
``storage_value`` maps model types onto the schema's CHECK values —
MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are
disambiguated by which fields are populated; CUSTOM_DAYS stores as
``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the
table stores (one element for MONTHLY_WEEKDAY, which names the
position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field
carried through from_dict/to_dict for losslessness and folded into
``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the
model-side lossless pair; the DB mapping lives in the DAO layer and is
exercised there.
Validation policy (fail fast at construction — the schema's CHECK
constraints are a second line of defense, but invalid rules must never
reach storage):
- ``interval`` >= 1 for every rule.
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans.
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is
rejected) with a weekday 0..6 for MONTHLY_WEEKDAY.
- ``month`` 1..12 is required for YEARLY.
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
when present, on or after ``start_date``.
Month-end policy (implemented in :func:`_occurs_monthly_day` and
documented for Feature 04): in each interval-eligible month, a
MONTHLY_DAY rule for a day that does not exist in that month — the
31st in April, the 30th or 29th in February, February 29th in a
non-leap year — fires on the LAST day of the month instead of being
skipped. A monthly chore still happens in every eligible month,
pinned to the month's end when its nominal date is absent; exactly one
day per eligible month matches the rule (nominal day when it exists,
month end when it does not). The interval anchors on nominal months
elapsed from start_date's month, so clamping never shifts the cadence.
The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence`
(extracted in task d0b691d8); this module re-exports it so existing
``from .recurrence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import datetime
import enum
from dataclasses import dataclass, field
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
_DATE_FORMAT = "%Y-%m-%d"
#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order).
class RuleType(enum.Enum):
"""The six recurrence shapes the engine supports.
``storage_value`` is the string written to the ``schedule_rules``
table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY
and MONTHLY_WEEKDAY are distinct shapes sharing one storage value,
disambiguated by which fields are populated.
"""
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY_DAY = "monthly_day"
MONTHLY_WEEKDAY = "monthly_weekday"
YEARLY = "yearly"
CUSTOM_DAYS = "custom_days"
@property
def storage_value(self) -> str:
"""The schema-level rule_type string this model type maps to."""
return {
RuleType.DAILY: "daily",
RuleType.WEEKLY: "weekly",
RuleType.MONTHLY_DAY: "monthly",
RuleType.MONTHLY_WEEKDAY: "monthly",
RuleType.YEARLY: "yearly",
RuleType.CUSTOM_DAYS: "custom",
}[self]
class RuleValidationError(ValueError):
"""Raised when a ScheduleRule field combination is invalid."""
def _validate_date(value: str, field_name: str) -> None:
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise RuleValidationError(
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise RuleValidationError(
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
)
def _validate_weekday_set(weekday_set) -> frozenset[int]:
"""Return a normalized frozenset or raise with a clear message.
Sequence entries are validated BEFORE set() coercion: a list like
[0, False] must not collapse the boolean into 0, and unhashable
entries must raise RuleValidationError, never a raw TypeError.
"""
if isinstance(weekday_set, (list, tuple)):
for entry in weekday_set:
# _is_plain_int: bool and int subclasses (potentially
# unhashable) are rejected before set() coercion.
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
if not isinstance(weekday_set, (set, frozenset)):
raise RuleValidationError(
f"weekday_set must be a set of weekday integers, got "
f"{type(weekday_set).__name__}"
)
if not weekday_set:
raise RuleValidationError(
"weekday_set must not be empty for weekly/custom rules"
)
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
if not 0 <= entry <= 6:
raise RuleValidationError(
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
f"got {entry!r}"
)
return frozenset(weekday_set)
def _is_plain_int(value) -> bool:
"""True only for real ints: bool excluded AND int subclasses too
(an unhashable int subclass would break the frozen-hashable model)."""
return type(value) is int
def _validate_monthly_weekday(nth_weekday, weekday) -> None:
"""Validate the MONTHLY_WEEKDAY position fields."""
if nth_weekday is None or not _is_plain_int(nth_weekday):
raise RuleValidationError(
"nth_weekday must be an integer for monthly_weekday rules, "
f"got {nth_weekday!r}"
)
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
raise RuleValidationError(
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
)
if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6:
raise RuleValidationError(
f"monthly_weekday rules require a weekday 0 (Monday)..6 "
f"(Sunday), got {weekday!r}"
)
@dataclass(frozen=True)
class ScheduleRule:
"""An immutable, validated recurrence rule (see module docstring).
Construct with keyword arguments; invalid combinations raise
:class:`RuleValidationError` at construction time. Fields map
one-to-one onto the ``schedule_rules`` table columns, with
``nth_weekday_weekday`` as the model-only weekday companion to
``nth_weekday`` (serialized inside ``weekday_set`` at the DAO
boundary).
"""
rule_type: RuleType
interval: int = 1
weekday_set: frozenset[int] | None = None
day_of_month: int | None = None
nth_weekday: int | None = None
nth_weekday_weekday: int | None = None
month: int | None = None
start_date: str = "1970-01-01"
end_date: str | None = None
def __post_init__(self) -> None:
# Normalize a string rule_type into the enum; anything else
# that is not already a RuleType is rejected.
if isinstance(self.rule_type, str):
# EXACT model names only: no whitespace stripping, no storage
# aliases. The DAO maps storage strings to model fields when
# reading rows; the model boundary stays unambiguous.
candidates = {rt.name.lower(): rt for rt in RuleType}
if self.rule_type in candidates:
object.__setattr__(
self, "rule_type", candidates[self.rule_type]
)
else:
raise RuleValidationError(
f"rule_type must be a RuleType or one of "
f"{sorted(candidates)}, got {self.rule_type!r}"
)
if not isinstance(self.rule_type, RuleType):
raise RuleValidationError(
f"rule_type must be a RuleType or one of its string "
f"values, got {self.rule_type!r}"
)
if not _is_plain_int(self.interval):
raise RuleValidationError(
f"interval must be an integer >= 1, got {self.interval!r}"
)
if self.interval < 1:
raise RuleValidationError(
f"interval must be >= 1, got {self.interval!r}"
)
_validate_date(self.start_date, "start_date")
if self.end_date is not None:
_validate_date(self.end_date, "end_date")
if self.end_date < self.start_date:
raise RuleValidationError(
f"end_date {self.end_date!r} must be on or after "
f"start_date {self.start_date!r}"
)
# Type-exclusive field policy, enforced for every type: build
# the dict of present fields, then reject every field the shape
# must not carry (weekday_set present is detectable via None vs
# set even after normalization below).
shape = self.rule_type
if shape is RuleType.DAILY:
forbidden_fields = (
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.WEEKLY:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.CUSTOM_DAYS:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.MONTHLY_DAY:
forbidden_fields = ("weekday_set", "nth_weekday",
"nth_weekday_weekday", "month")
elif shape is RuleType.MONTHLY_WEEKDAY:
forbidden_fields = ("weekday_set", "day_of_month", "month")
elif shape is RuleType.YEARLY:
forbidden_fields = (
"weekday_set",
"nth_weekday",
"nth_weekday_weekday",
)
present_forbidden = [
name
for name in forbidden_fields
if getattr(self, name) is not None
]
if present_forbidden:
raise RuleValidationError(
f"{shape.value} rules must not set "
f"{', '.join(sorted(present_forbidden))}"
)
# Required fields per shape.
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
normalized = _validate_weekday_set(self.weekday_set)
object.__setattr__(self, "weekday_set", normalized)
elif shape is RuleType.MONTHLY_DAY:
if self.day_of_month is None or not _is_plain_int(
self.day_of_month
):
raise RuleValidationError(
"monthly_day rules require day_of_month (1..31)"
)
if not 1 <= self.day_of_month <= 31:
raise RuleValidationError(
f"day_of_month must be 1..31, got {self.day_of_month!r}"
)
elif shape is RuleType.MONTHLY_WEEKDAY:
_validate_monthly_weekday(
self.nth_weekday, self.nth_weekday_weekday
)
elif shape is RuleType.YEARLY:
if self.month is None or not _is_plain_int(self.month):
raise RuleValidationError(
"yearly rules require month (1..12)"
)
if not 1 <= self.month <= 12:
raise RuleValidationError(
f"month must be 1..12, got {self.month!r}"
)
if self.day_of_month is not None and not (
_is_plain_int(self.day_of_month)
and 1 <= self.day_of_month <= 31
):
raise RuleValidationError(
f"day_of_month must be 1..31 for yearly rules, got "
f"{self.day_of_month!r}"
)
def to_dict(self) -> dict:
"""Return the lossless dict form (model-side storage shape).
``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's
weekday travels inside it (appended) plus
``nth_weekday_weekday`` so the pair round-trips without
ambiguity at the model layer.
"""
return {
"rule_type": self.rule_type.name.lower(), # model name
"interval": self.interval,
"weekday_set": (
sorted(self.weekday_set) if self.weekday_set is not None
else None
),
"day_of_month": self.day_of_month,
"nth_weekday": self.nth_weekday,
"nth_weekday_weekday": self.nth_weekday_weekday,
"month": self.month,
"start_date": self.start_date,
"end_date": self.end_date,
}
@classmethod
def from_dict(cls, data: dict) -> "ScheduleRule":
"""Rebuild a rule from :meth:`to_dict` output.
Raises :class:`RuleValidationError` on unknown keys, missing
keys, or invalid combinations — never a raw TypeError.
"""
if not isinstance(data, dict):
raise RuleValidationError(
f"rule data must be a dict, got {type(data).__name__}"
)
allowed = {
"rule_type",
"interval",
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
"start_date",
"end_date",
}
unknown = [key for key in data if key not in allowed]
if unknown:
raise RuleValidationError(
f"unknown rule fields: {sorted(unknown, key=repr)}"
)
if "rule_type" not in data:
raise RuleValidationError("missing required rule field: rule_type")
weekday_set = data.get("weekday_set")
if isinstance(weekday_set, (list, tuple)):
# Validate entries as plain ints BEFORE set() coercion, so a
# list like [0, False] cannot collapse False into 0 and an
# unhashable/int-subclass entry cannot leak a raw TypeError.
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
return cls(
rule_type=data["rule_type"],
interval=data.get("interval", 1),
weekday_set=weekday_set,
day_of_month=data.get("day_of_month"),
nth_weekday=data.get("nth_weekday"),
nth_weekday_weekday=data.get("nth_weekday_weekday"),
month=data.get("month"),
start_date=data.get("start_date", "1970-01-01"),
end_date=data.get("end_date"),
)
# ---------------------------------------------------------------------------
# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule
# ---------------------------------------------------------------------------
def _parse_date(value: str) -> datetime.date:
"""Parse a strict ISO date already validated by the model."""
return datetime.datetime.strptime(value, _DATE_FORMAT).date()
def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int:
"""Day offset from the rule's anchor date to ``target``.
The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the
first day the rule CAN fire. A negative offset means the target
precedes the rule's window entirely.
"""
return (target - _parse_date(rule.start_date)).days
def _within_window(rule: ScheduleRule, target: datetime.date) -> bool:
"""True when ``target`` is inside the rule's [start, end] window."""
if target < _parse_date(rule.start_date):
return False
if rule.end_date is not None and target > _parse_date(rule.end_date):
return False
return True
def _month_end(year: int, month: int) -> int:
"""The last calendar day of the given month (leap-aware)."""
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
"""MONTHLY_DAY evaluation with the documented month-end policy.
Policy: in each interval-eligible month, a rule for a day-of-month
that does not exist in that month (the 31st in April, the 30th in
February, the 29th of February in a non-leap year) fires on the
LAST day of that month instead of being skipped — the chore still
happens that month, pinned to the month's end when its nominal date
is absent.
The interval anchors on months ELAPSED from start_date's month: the
rule fires when the months between the anchor month and the target's
month form a multiple of the interval, keeping "every 2 months on
the 15th" aligned to the anchor month even when the firing lands on
a clamped month-end day.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def _nth_weekday_of_month(
year: int, month: int, weekday: int, nth: int
) -> int | None:
"""The day-of-month of the ``nth`` ``weekday``, or None when the
month has no such occurrence (``nth`` 5 beyond four occurrences).
Positive ``nth`` counts forward (1 = first); ``-1`` counts from the
end (last occurrence, whatever the total). Pure calendar math.
"""
if nth == -1:
last = datetime.date(year, month, _month_end(year, month))
back = (last.weekday() - weekday) % 7
return last.day - back
first = datetime.date(year, month, 1)
first_offset = (weekday - first.weekday()) % 7
day = 1 + first_offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
def _occurs_monthly_weekday(
rule: ScheduleRule, target: datetime.date
) -> bool:
"""MONTHLY_WEEKDAY evaluation.
Fires when the target is the ``nth`` occurrence of
``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A
month with fewer occurrences than ``nth`` (5 on a four-occurrence
month) does not fire — n 5 is accepted by the model but skips such
months, matching the done-condition. The interval anchors on
nominal months elapsed from start_date's month.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
if target.weekday() != rule.nth_weekday_weekday:
return False
nth_day = _nth_weekday_of_month(
target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday
)
return nth_day is not None and target.day == nth_day
def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool:
"""YEARLY evaluation with the documented leap-day clamp policy.
Policy: in each interval-eligible year, the rule fires on
``day_of_month`` of ``month`` when that day exists. A YEARLY rule
for February 29th fires on FEBRUARY 28TH in a non-leap year (the
same clamp-as-month-end policy as MONTHLY_DAY: the annual chore
still happens every year, pinned to February's end when its
nominal date is absent) rather than silently vanishing. A YEARLY
rule with NO day_of_month fires on the first day of ``month``.
The interval anchors on years elapsed from start_date's year.
"""
anchor = _parse_date(rule.start_date)
years_elapsed = target.year - anchor.year
if years_elapsed % rule.interval != 0:
return False
if target.month != rule.month:
return False
if rule.day_of_month is None:
# Day-less yearly rules fire on the first of the month.
return target.day == 1
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
"""Return True when ``rule`` fires on ``target``.
Pure function: no I/O, no timezone awareness, no mutation. Dates
are plain calendar dates (Feature 04 guardrail); the caller decides
the timezone that produced them.
"""
# Dispatch FIRST: an unimplemented shape must raise loudly even for
# dates outside the window, never silently evaluate to False.
shape = rule.rule_type
if shape not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
):
raise NotImplementedError(
f"occurs_on for {shape} lands with its own engine task "
"(Features 04 task sequence)"
)
if not _within_window(rule, target):
return False
if shape is RuleType.DAILY:
offset = (target - _parse_date(rule.start_date)).days
return offset % rule.interval == 0
if shape is RuleType.MONTHLY_DAY:
return _occurs_monthly_day(rule, target)
if shape is RuleType.MONTHLY_WEEKDAY:
return _occurs_monthly_weekday(rule, target)
if shape is RuleType.YEARLY:
return _occurs_yearly(rule, target)
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
# parity on January 1st; anchor arithmetic does not).
anchor = _parse_date(rule.start_date)
if shape is RuleType.CUSTOM_DAYS:
interval = 1
else:
interval = rule.interval
week_offset = (target - anchor).days // 7
if week_offset % interval != 0:
return False
return target.weekday() in rule.weekday_set
def occurrences_between(
rule: ScheduleRule, start: str, end: str
) -> list[str]:
"""Return every date string in [start, end] the rule fires on.
Inclusive on both ends. Raises RuleValidationError on malformed or
inverted bounds; a window entirely outside [rule.start_date,
rule.end_date] yields an empty list.
"""
if rule.rule_type not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
):
raise NotImplementedError(
f"occurrences_between for {rule.rule_type} lands with its own "
"engine task (Features 04 task sequence)"
)
_validate_date(start, "start")
_validate_date(end, "end")
start_date = _parse_date(start)
end_date = _parse_date(end)
if end_date < start_date:
raise RuleValidationError(
f"end {end!r} must be on or after start {start!r}"
)
rule_start = _parse_date(rule.start_date)
if end_date < rule_start:
return []
# Clamp the walk to the rule's own window and the queried range.
effective_start = max(start_date, rule_start)
effective_end = end_date
if rule.end_date is not None:
effective_end = min(effective_end, _parse_date(rule.end_date))
results: list[str] = []
cursor = effective_start
while cursor <= effective_end:
if occurs_on(rule, cursor):
results.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
return results
from .core.recurrence import * # noqa: F401,F403
+7 -423
View File
@@ -1,427 +1,11 @@
"""Declarative schema DDL for the NestQuest SQLite database.
"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package.
Every v1 table is declared here as plain SQL statements so later schema
tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without
touching this module's tables. All statements use
``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned
migration runner is a separate later concern.
Timestamp policy: all timestamps are UTC ISO-8601 strings, set by the
caller/DAO rather than a DB default, per the feature guardrails. No
``length(created_at) = 25`` CHECK is added: the exact string shape
produced by callers is not fixed yet (``datetime.isoformat()`` may
include microseconds, changing the length), so enforcing a fixed length
here would reject valid values. Only non-null is enforced for now.
Date policy: ``start_date``/``end_date`` are ISO date strings (YYYY-MM-DD,
no time component) compared lexically by the ``end_date >= start_date``
CHECK; due_date/due_time string shapes are caller-validated by the rule
engine and DAO layers, not by DDL.
``weekday_set`` format: comma-separated weekday integers (CSV), 0 =
Monday .. 6 = Sunday, e.g. ``'0,2,4'`` for Mon/Wed/Fri. Chosen over JSON
because it sorts, compares and parses trivially in SQL. The format is
enforced at the DB level: a CHECK matches ``weekday_set`` against GLOB
patterns for element counts 1..7 of single digits 0-6 (GLOB has no
repetition quantifier, so the element count is enumerated; more than 7
elements is impossible for a weekday set). ``''``, ``'x'``, ``'7'`` and
``'0,x'`` are all rejected; NULL stays allowed for non-weekly rules.
This keeps the schema layer storage-only — the DAO layer (tasks
8c72300c+) does not need its own validator, it can rely on the CHECK.
Type affinity: SQLite applies column affinity on insert, but CHECKs
still observe text and fractional values ('abc' stays text, '1.5'
becomes REAL), so every INTEGER column carrying rule semantics carries
a ``typeof(...) = 'integer'`` guard in its CHECK.
Rule-type coherence: two CHECKs enforce that ``weekly`` carries a
non-null ``weekday_set``, ``monthly`` carries a ``day_of_month`` or an
``nth_weekday``, ``yearly`` carries a ``month``, and ``daily``/``custom``
carry none of the month-specific fields. Relaxed deliberately: the DDL
does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks"
rule or a monthly rule could later be pinned to a month) nor extra
fields on weekly/monthly/yearly rules generally, so the CHECKs only
reject what the rule engine definitively cannot express now.
Presence pattern format: ``presence_schedules.pattern`` encodes the
whole N-week repeating cycle in one TEXT column as pipe-separated
segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
week 1. An empty segment means the child is absent every day of that
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means
absent every day of every week, which is distinct from having no
schedule row at all (a child with no schedule is present every day, per
the feature guardrails). Chosen over JSON or per-week rows to stay
consistent with the CSV ``weekday_set`` convention and fully checkable
by plain CHECKs. DB-level validation is pure CHECK, no triggers or
custom functions: char-class GLOBs reject every character outside
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
(double comma, comma next to a pipe, leading/trailing comma) and
two-digit runs; a further GLOB rejects contiguous runs of 8+ comma
separated digits, which caps a segment at 7 elements — the GLOB cannot
match across a ``|``, so cross-segment boundaries stay safe; and the
segment count is pinned to ``cycle_length_weeks`` by
``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per
weekday_set this is not full CSV-set normalisation (duplicates like
``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``);
the engine treats the segment as a set. A
one-row-per-``week_index`` normalization was considered and rejected:
the flat single-column encoding keeps the done-condition's table shape
and proved fully checkable with plain CHECKs.
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
common case, and a small cap keeps anchor arithmetic and the encoded
pattern human-readable. Revisit the cap — and re-run the pattern
validation matrix in tests — if a longer cycle is ever needed.
``anchor_date`` is an ISO date string pinning the cycle: the week index
of a date is (days since anchor) // 7 mod ``cycle_length_weeks``,
computed by the presence engine (Feature 05) from anchor-date
arithmetic, never ISO week parity. Like other date columns it carries
no shape CHECK, matching the established date policy.
Presence overrides: ``presence_overrides.end_date`` is NOT NULL,
unlike ``schedule_rules.end_date`` — an override is always a concrete
date range; a single-day override stores the same date in both columns.
``is_present`` 0/1 marks the child absent/present for the whole range;
``note`` is optional free text.
Multi-assignee definitions (D-008): ``quest_definitions`` carries no
``child_id`` column. Assignment lives in
``quest_definition_assignees``, one row per (definition, child) with a
composite primary key and foreign keys to the definition and the child,
so "brush teeth" is one definition covering three children. The
migration runner rebuilds a pre-D-008 table into this shape and copies
its single-assignee column across. The composite primary key makes
duplicate assignment impossible at the storage layer.
Quest windows (D-008): ``quest_definition_windows`` declares which day
windows a definition spans — one row per (definition, window), the
composite primary key making a window idempotent per definition.
``window`` is CHECK-constrained to the three spellings ``const`` owns;
``due_time`` optionally pins a due time inside the window and is
caller-validated (strict HH:MM) by the DAO layer, matching the date
policy above. The window clock ranges live in ``const.py``, not here:
this layer stores what the caller declares and never interprets clocks.
Quest instances: ``quest_instances`` deliberately carries NO completion
status column. An instance's current state derives from the latest row
in ``completion_events`` (Feature 08), so materialization (Feature 07)
can insert instances without knowing anything about completion and the
append-only log stays the single source of truth. ``definition_id``,
``child_id``, ``due_date`` and ``window`` snapshot the
materialization-time facts ("why this instance exists"): assignment
edits and rule edits change future instances only and never rewrite
these rows. ``window`` records which declared day window produced the
instance (D-008), CHECK-constrained to the same three spellings
``const`` owns. ``due_time`` snapshots the window's optional due time
at generation for the same reason — the instance keeps its
generation-time value even if the definition is edited later.
``generated_at`` is a caller-set UTC ISO-8601 timestamp per the module
timestamp policy (no DB default, so a generation batch stamps one
coherent time).
Uniqueness: the (definition_id, child_id, due_date, window) tuple is
declared as a table-level ``UNIQUE`` constraint rather than a separate
``CREATE UNIQUE INDEX`` statement. SQLite materializes it as an
implicit unique index (``sqlite_autoindex_quest_instances_1``) — the
required "unique index prevents duplicate instances" — and the
constraint form cannot be silently dropped the way a standalone index
can, so the duplicate door stays shut. Idempotent materialization keys
on exactly this tuple: the twice-daily case (one definition, two
windows, one child, one date) yields two rows and the shared case (one
definition, three assignees, one window) yields three. No secondary
indexes beyond it are declared yet: history and board queries (Features
08/10/13) should justify their indexes as a later migration once their
query shapes exist.
No ``ON DELETE`` clauses are declared on any foreign key in this
module: children and definitions are deactivated, never deleted, so a
deletion that would orphan instances or events is a bug and must fail
loudly rather than cascade.
Completion events: ``completion_events`` is strictly append-only. That
property is enforced at the DAO layer — the completion-events DAO
exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT
by database triggers, matching the module's no-trigger policy: a RAISE
trigger would be the one exception to "pure CHECK, no triggers",
complicate the later migration runner, and still prove nothing (any
writer can run raw SQL). ``event_type`` is constrained to
``completed``/``uncompleted``; a ``missed`` row is intentionally not
representable — missed is a derived state announced on the HA event bus
(Feature 10), never an appended event.
``child_id`` is deliberately denormalized (it is also derivable via
``quest_instances``): history reporting (Feature 13) filters and rates
per child directly against the log, and the log should stay
self-describing without joins.
Actor policy: ``actor_source`` distinguishes an authenticated HA user
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
panel tap with no user context (``'panel'``, with ``actor_user_id``
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
Other actor sources are rejected; if an unattended system actor is
ever needed, that is a schema migration, not a silent widening.
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
``completed`` events — at completion time the due date is known and the
flag is decidable. For ``uncompleted`` events the flag is optional:
Feature 08 decides whether a reversal carries the reversed completion's
on-time flag or NULL, and the DDL deliberately accepts both rather than
guessing.
The declarative schema DDL lives in
:mod:`custom_components.nestquest.core.schema` (extracted in
task d0b691d8); this module re-exports it so existing
``from .schema import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .db import NestQuestDatabase
#: GLOB patterns matching a valid ``weekday_set`` CSV: one GLOB per
#: possible element count 1..7 (a weekday set cannot exceed 7 elements).
#: Each element is a single digit 0-6, separated by a literal comma.
#: GLOB has no repetition quantifier, so the element count is enumerated.
#: ``''`` and NULL are deliberately NOT matched: NULL is allowed for
#: non-weekly rules via a separate ``IS NULL`` arm, and '' is invalid
#: because weekly rules need at least one weekday.
WEEKDAY_SET_GLOB_PATTERNS: list[str] = [
"[0-6]",
"[0-6],[0-6]",
"[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
]
SCHEMA_V1_CHILDREN_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
colour TEXT,
avatar_ref TEXT,
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (typeof(sort_order) = 'integer'),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)
""",
]
SCHEMA_V1_ADMIN_USERS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS admin_users (
ha_user_id TEXT PRIMARY KEY NOT NULL,
added_at TEXT NOT NULL
)
""",
]
_WEEKDAY_SET_CHECK = " OR ".join(
f"weekday_set GLOB '{pattern}'" for pattern in WEEKDAY_SET_GLOB_PATTERNS
)
SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS schedule_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_type TEXT NOT NULL CHECK (rule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')),
interval INTEGER NOT NULL DEFAULT 1 CHECK (typeof(interval) = 'integer' AND interval >= 1),
weekday_set TEXT CHECK (weekday_set IS NULL OR ({_WEEKDAY_SET_CHECK})),
day_of_month INTEGER CHECK (day_of_month IS NULL OR (typeof(day_of_month) = 'integer' AND day_of_month >= 1 AND day_of_month <= 31)),
nth_weekday INTEGER CHECK (nth_weekday IS NULL OR (typeof(nth_weekday) = 'integer' AND nth_weekday IN (-1, 1, 2, 3, 4, 5))),
month INTEGER CHECK (month IS NULL OR (typeof(month) = 'integer' AND month >= 1 AND month <= 12)),
start_date TEXT NOT NULL,
end_date TEXT,
CHECK (end_date IS NULL OR end_date >= start_date),
CHECK (
(rule_type = 'weekly' AND weekday_set IS NOT NULL)
OR (rule_type = 'monthly' AND (day_of_month IS NOT NULL OR nth_weekday IS NOT NULL))
OR (rule_type = 'yearly' AND month IS NOT NULL)
OR (rule_type IN ('daily', 'custom') AND day_of_month IS NULL AND nth_weekday IS NULL AND month IS NULL)
)
)
""",
]
#: Column body of ``quest_definitions``, shared between the v1 DDL
#: below and the multi-assignee rebuild migration, which must recreate
#: the table in exactly this shape (D-008: one definition, many
#: assignees via ``quest_definition_assignees`` — no ``child_id``
#: column on the definition itself).
QUEST_DEFINITIONS_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)"""
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS quest_definitions {QUEST_DEFINITIONS_TABLE_SQL}
""",
]
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS quest_definition_assignees (
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
PRIMARY KEY (definition_id, child_id)
)
""",
]
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS quest_definition_windows (
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
due_time TEXT,
PRIMARY KEY (definition_id, window)
)
""",
]
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS presence_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL CHECK (
NOT pattern GLOB '*[^0-6,|]*'
AND NOT pattern GLOB '*[0-6][0-6]*'
AND NOT pattern GLOB '*,,*'
AND NOT pattern GLOB '*,|*'
AND NOT pattern GLOB '*|,*'
AND NOT pattern GLOB ',*'
AND NOT pattern GLOB '*,'
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
)
)
""",
]
SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS presence_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL REFERENCES children(id),
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)),
note TEXT,
CHECK (end_date >= start_date)
)
""",
]
#: Column body of ``quest_instances``, shared between the v1 DDL
#: below and the instance-key rebuild migration, which must recreate
#: the table in exactly this shape (D-008: the instance key is
#: (definition_id, child_id, due_date, window) — one instance per
#: assigned child per declared window per firing date).
QUEST_INSTANCES_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
due_date TEXT NOT NULL,
due_time TEXT,
generated_at TEXT NOT NULL,
UNIQUE (definition_id, child_id, due_date, window)
)"""
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL}
""",
]
#: Column body of ``completion_events``, shared between the v1 DDL
#: below and the actor-child rebuild migration, which must recreate the
#: table in exactly this shape (D-008: ``actor_child_id`` records which
#: panel profile was tapped — set for panel events, NULL for admin
#: ones; legacy panel rows backfill to ``child_id``, the child they
#: always were).
COMPLETION_EVENTS_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
child_id INTEGER NOT NULL REFERENCES children(id),
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
actor_user_id TEXT,
actor_child_id INTEGER REFERENCES children(id),
occurred_at TEXT NOT NULL,
was_on_time INTEGER CHECK (
was_on_time IS NULL
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
),
CHECK (
(actor_source = 'user' AND actor_user_id IS NOT NULL)
OR (actor_source = 'panel' AND actor_user_id IS NULL)
),
CHECK (
(actor_source = 'user' AND actor_child_id IS NULL)
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
),
CHECK (
(event_type = 'completed' AND was_on_time IS NOT NULL)
OR (event_type = 'uncompleted')
)
)"""
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
""",
]
#: Generic integration-owned key/value state (schema version 7,
#: Feature 11): persistent bookkeeping that belongs to the integration
#: rather than any domain table — currently the missed-sweep
#: watermark. ``value`` is a plain string; callers interpret it.
META_STATE_TABLE_SQL = """(
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL
)"""
SCHEMA_V7_META_STATE_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL}
""",
]
SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL,
*SCHEMA_V1_SCHEDULE_RULES_DDL,
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
*SCHEMA_V7_META_STATE_DDL,
]
async def async_apply_ddl(
database: NestQuestDatabase, statements: list[str]
) -> None:
"""Apply each DDL statement inside one transaction on ``database``.
Idempotent: every statement is ``CREATE TABLE IF NOT EXISTS``, so
applying the same list twice leaves the schema and its rows intact.
"""
async with database.transaction():
for sql in statements:
await database.execute(sql)
from .core.schema import * # noqa: F401,F403
+7 -42
View File
@@ -1,46 +1,11 @@
"""Explicit admin-only versus open service registry (Feature 09).
"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package.
One mapping, one file: every registered ``nestquest`` service name is
either ``open`` or ``admin_only``. The permission decorator (later
Feature 09 tasks) reads this table; it does not take per-handler
policy arguments.
The service policy registry lives in
:mod:`custom_components.nestquest.core.service_policy` (extracted in
task 510c1f78); this module re-exports it so existing
``from .service_policy import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .const import (
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,
SERVICE_UPDATE_QUEST_DEFINITION,
)
OPEN = "open"
ADMIN_ONLY = "admin_only"
SERVICE_POLICY: dict[str, str] = {
SERVICE_COMPLETE_QUEST: OPEN,
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
SERVICE_REGENERATE: ADMIN_ONLY,
}
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
raise RuntimeError(
"SERVICE_POLICY must name exactly the registered nestquest services, "
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
)
from .core.service_policy import * # noqa: F401,F403
+1 -8
View File
@@ -24,8 +24,6 @@ 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,
@@ -258,12 +256,7 @@ async def _refresh_entities(runtime: Any) -> None:
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}
return {"today": today, "horizon_days": runtime.settings.horizon_days}
def _actor_kwargs(call: Any) -> dict[str, Any]:
+22 -10
View File
@@ -1,20 +1,32 @@
"""SQLite storage path resolution for the NestQuest integration."""
"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package.
The path resolver lives in
:mod:`custom_components.nestquest.core.store` (extracted in task
d0b691d8) and is Home-Assistant-free:
:func:`custom_components.nestquest.core.store.async_get_db_path`
takes the full database path. This module re-exports it and provides
the original ``async_get_db_path(hass)`` signature so the integration
resolves the path from ``hass.config.path`` exactly as before.
"""
from __future__ import annotations
from pathlib import Path
from homeassistant.core import HomeAssistant
from .const import SQLITE_DB_FILENAME
from .core.const import SQLITE_DB_FILENAME
from .core.store import async_get_db_path as _core_async_get_db_path
from .core.store import _ensure_dir
def _ensure_dir(path: Path) -> None:
"""Create the given directory and any missing parents."""
path.mkdir(parents=True, exist_ok=True)
__all__ = ["async_get_db_path", "_ensure_dir"]
async def async_get_db_path(hass: HomeAssistant) -> Path:
"""Return the absolute path to the NestQuest SQLite database file."""
db_path = Path(hass.config.path(SQLITE_DB_FILENAME)).absolute()
await hass.async_add_executor_job(_ensure_dir, db_path.parent)
return db_path
"""Return the absolute path to the NestQuest SQLite database file.
Resolves the database file from ``hass.config.path`` and delegates to
:func:`custom_components.nestquest.core.store.async_get_db_path`,
which ensures the parent directory exists off the event loop via
:func:`asyncio.to_thread`.
"""
return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME))
+56 -1
View File
@@ -22,6 +22,7 @@ pytest -q``); it is never meant to run against a real HA install.
from __future__ import annotations
import asyncio
import datetime
import importlib.util
import inspect
import sys
@@ -829,6 +830,24 @@ def make_hass() -> tuple:
return hass, registry
def executor_for(hass):
"""Return a dynamic executor callable backed by ``hass.async_add_executor_job``.
The wrapper resolves ``hass.async_add_executor_job`` on every call, so a
test that swaps it after constructing the database (to gate or fail
specific jobs) is observed without rebuilding the wrapper — mirroring
the pre-extraction db.py, which read the attribute dynamically off
``hass``. Use this for cancellation/gating tests that reassign
``hass.async_add_executor_job``; plain call sites can pass
``hass.async_add_executor_job`` directly.
"""
async def _executor(fn, *args, **kwargs):
return await hass.async_add_executor_job(fn, *args, **kwargs)
return _executor
@pytest.fixture
async def hass():
"""Provide a standard-shaped hass fixture bound to the running test loop.
@@ -898,4 +917,40 @@ def make_config_flow_entry():
def _factory(domain: str, entry_id: str = "existing"):
return SimpleNamespace(domain=domain, entry_id=entry_id)
return _factory
return _factory
def freeze_nestquest_clock(
monkeypatch, instant: datetime.datetime
) -> tuple[datetime.datetime, list]:
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
Only ``custom_components.nestquest``'s ``datetime`` binding is patched,
so the generation path's ``datetime.datetime.now`` reads this instant
(localized to whatever time zone it asks for) while every other module
and the test harness keep the real clock. Returns ``(frozen, requested)``
where ``frozen`` is the instant normalized to UTC and ``requested`` is
the list of ``tzinfo`` objects the code under test passed to ``now`` —
so a caller can prove the generation path actually requested the
configured HA time zone rather than silently reading UTC.
"""
import custom_components.nestquest as nestquest
frozen = instant.astimezone(datetime.timezone.utc)
requested: list = []
class _FrozenDatetime(datetime.datetime):
@classmethod
def now(cls, tz=None):
requested.append(tz)
if tz is None:
return frozen.replace(tzinfo=None)
return frozen.astimezone(tz)
fake_module = SimpleNamespace(
datetime=_FrozenDatetime,
timedelta=datetime.timedelta,
date=datetime.date,
)
monkeypatch.setattr(nestquest, "datetime", fake_module)
return frozen, requested
+1 -1
View File
@@ -31,7 +31,7 @@ def _make_hass_mock():
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / name)
try:
await apply_migrations(database)
+6 -6
View File
@@ -33,7 +33,7 @@ def _make_hass_mock():
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / name)
try:
await apply_migrations(database)
@@ -141,7 +141,7 @@ def test_create_child_duplicate_name_allowed_and_warned(
async def _body(database):
first = await create_child(database, "Ada")
with caplog.at_level(
logging.WARNING, logger="custom_components.nestquest.children"
logging.WARNING, logger="custom_components.nestquest.core.children"
):
second = await create_child(database, " ADA ")
# Whitespace/case-normalised duplicate: allowed, both rows exist.
@@ -360,12 +360,12 @@ def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None:
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "create-race.db")
try:
await apply_migrations(database)
logger = logging.getLogger(
"custom_components.nestquest.children"
"custom_components.nestquest.core.children"
)
seen: list[logging.LogRecord] = []
@@ -633,7 +633,7 @@ def test_edit_child_concurrent_opposite_edits_return_own_values(
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "edit-race.db")
try:
await apply_migrations(database)
@@ -685,7 +685,7 @@ def test_set_child_active_concurrent_opposite_transitions_return_own(
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "active-race.db")
try:
await apply_migrations(database)
+3 -3
View File
@@ -61,7 +61,7 @@ D1 = _future_day(1)
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
children = ChildrenDao(database)
@@ -630,7 +630,7 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "complete-race.db")
try:
await apply_migrations(database)
@@ -1060,7 +1060,7 @@ def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "uncomplete-race.db")
try:
await apply_migrations(database)
+1 -1
View File
@@ -140,7 +140,7 @@ def test_flow_strings_match_translations(
def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
"""Ensure no module other than const.py hardcodes the domain or db filename."""
pkg_dir = Path(config_flow.__file__).parent
py_files = [f for f in pkg_dir.glob("*.py") if f.name not in ("const.py",)]
py_files = [f for f in pkg_dir.rglob("*.py") if f.name not in ("const.py",)]
assert len(py_files) > 0, "No python modules found in package to check"
+1 -1
View File
@@ -106,7 +106,7 @@ def test_window_clock_ranges_cover_every_window() -> None:
def test_no_hardcoded_domain_or_db_filename_in_modules() -> None:
"""Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME."""
pkg_dir = Path(const.__file__).parent
py_files = [f for f in pkg_dir.glob("*.py") if f.name != "const.py"]
py_files = [f for f in pkg_dir.rglob("*.py") if f.name != "const.py"]
assert len(py_files) > 0, "No python modules found in package to check"
+120
View File
@@ -0,0 +1,120 @@
"""Invariant: the bundled core package is self-contained.
Every module under ``custom_components/nestquest/core/`` must be free of
Home Assistant and must not climb out of the core package: no
``homeassistant`` import, no relative import with level > 1, no absolute
import of ``custom_components.nestquest.<something other than core>``,
and no bare ``import core`` / ``from core``. This guards the whole
core package — the storage-layer extraction (task d0b691d8) and the
engines/business-layer extraction (task 510c1f78) — from regressing
into a HA-coupled or integration-coupled bundle.
"""
from __future__ import annotations
import ast
from pathlib import Path
CORE_PKG = Path(
__import__(
"custom_components.nestquest.core", fromlist=["__file__"]
).__file__
).parent
def _module_chain(base: str, name: str) -> str:
"""Combined dotted path for ``from <base> import <name>``."""
return f"{base}.{name}" if base else name
def _is_homeassistant(module: str) -> bool:
return module == "homeassistant" or module.startswith("homeassistant.")
def _climbs_out_of_core(module: str) -> bool:
"""An absolute path that reaches the integration package but not core."""
if module == "custom_components.nestquest":
return True
if module.startswith("custom_components.nestquest."):
rest = module[len("custom_components.nestquest."):]
head = rest.split(".", 1)[0]
return head != "core"
return False
def _is_bare_core(module: str) -> bool:
"""A bare ``core`` / ``core.*`` absolute reference (not the bundle)."""
return module == "core" or module.startswith("core.")
def _scan(path: Path) -> list[str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
offenders: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
mod = alias.name
if _is_homeassistant(mod):
offenders.append(
f"{path.name}:{node.lineno} homeassistant import: import {mod}"
)
if _is_bare_core(mod):
offenders.append(
f"{path.name}:{node.lineno} bare core import: import {mod}"
)
if _climbs_out_of_core(mod):
offenders.append(
f"{path.name}:{node.lineno} climbs out of core: import {mod}"
)
elif isinstance(node, ast.ImportFrom):
if node.level and node.level > 1:
offenders.append(
f"{path.name}:{node.lineno} relative import climbs out "
f"of core (level {node.level})"
)
continue
if node.level:
# relative level 1 stays inside core; nothing to check.
continue
base = node.module or ""
if _is_homeassistant(base):
offenders.append(
f"{path.name}:{node.lineno} homeassistant import: from {base}"
)
if _is_bare_core(base):
offenders.append(
f"{path.name}:{node.lineno} bare core import: from {base}"
)
if _climbs_out_of_core(base):
offenders.append(
f"{path.name}:{node.lineno} climbs out of core: from {base}"
)
for alias in node.names:
combined = _module_chain(base, alias.name)
if _is_homeassistant(combined):
offenders.append(
f"{path.name}:{node.lineno} homeassistant import: "
f"from {base} import {alias.name}"
)
if _is_bare_core(combined):
offenders.append(
f"{path.name}:{node.lineno} bare core import: "
f"from {base} import {alias.name}"
)
if _climbs_out_of_core(combined):
offenders.append(
f"{path.name}:{node.lineno} climbs out of core: "
f"from {base} import {alias.name}"
)
return offenders
def test_core_package_is_self_contained() -> None:
"""No core module imports homeassistant or climbs out of the core package."""
py_files = sorted(CORE_PKG.rglob("*.py"))
assert py_files, "core package not found under custom_components/nestquest/core"
all_offenders: list[str] = []
for py_file in py_files:
all_offenders.extend(_scan(py_file))
assert all_offenders == [], (
"core package isolation violated:\n" + "\n".join(all_offenders)
)
+230
View File
@@ -0,0 +1,230 @@
"""No-HA end-to-end smoke test (task 628473ad).
The bundled core package (``custom_components.nestquest.core``) is
self-contained and Home-Assistant-free, but the parent integration
package ``custom_components.nestquest`` is NOT: its ``__init__.py``
imports ``homeassistant``. A genuine "core runs with no Home
Assistant installed" proof therefore cannot live inside the suite
process — the suite's ``conftest.py`` installs mock ``homeassistant``
modules globally before any test imports the integration, so an
in-process exercise can never distinguish "core never touched HA"
from "conftest already stubbed HA for us".
This module runs the smoke test in a SUBPROCESS (``sys.executable -c
<script>``) that starts with a clean ``sys.modules`` and no
``homeassistant`` installed. The subprocess script:
- asserts ``homeassistant`` is NOT in ``sys.modules`` before importing
core;
- installs a STUB parent package ``custom_components.nestquest`` (a
bare :class:`types.ModuleType` whose ``__path__`` points at the real
``custom_components/nestquest`` directory) into ``sys.modules``
WITHOUT executing its ``__init__.py`` — so importing
``custom_components.nestquest.core.<x>`` resolves the real core
subpackage without dragging the integration's HA imports along;
- installs a ``custom_components`` namespace entry too, so the
subpackage resolves;
- imports the core modules (db, migrations, children,
quest_definitions, materialize, completion, recurrence);
- asserts ``homeassistant`` is STILL not in ``sys.modules`` after
those imports;
- opens a FRESH SQLite file, applies migrations, creates two children
and one quest definition (a daily recurrence rule with a start date
and one ``morning`` window), runs :func:`materialize` over a 28-day
window, completes one instance via :func:`complete_instance`
(``actor_source='panel'``), and derives the instance state via
:func:`instance_state`, asserting it is ``done``;
- prints a clear success marker.
The pytest test asserts the subprocess exit code is 0 and the marker
is printed, and fails with the subprocess stderr otherwise. This
guards the storage/engines/business extraction (tasks d0b691d8 /
510c1f78 / 4dd8f870) from regressing into a HA-coupled bundle: the
moment a core module grows a ``homeassistant`` import (direct or via
the parent package's ``__init__``), this test fails in a process
where HA is genuinely absent.
"""
from __future__ import annotations
import subprocess
import sys
import textwrap
from pathlib import Path
import pytest
#: The marker the subprocess prints on success. Kept short and unique
#: so the parent test can assert it survived intact in stdout.
SUCCESS_MARKER = "NO-HA SMOKE TEST OK"
#: The repo root, so the subprocess can locate the real
#: ``custom_components/nestquest`` directory regardless of its own
#: working directory.
_REPO_ROOT = Path(__file__).resolve().parent.parent
#: The smoke-test script, run with ``sys.executable -c``. It is a
#: real no-HA process: the suite's conftest stubs do not exist here,
#: and ``homeassistant`` is not installed in this venv, so any core
#: import of HA (direct or via the parent package ``__init__``) raises
#: ``ModuleNotFoundError`` and the subprocess exits non-zero.
_SCRIPT = textwrap.dedent(
"""\
import asyncio
import datetime
import sys
import tempfile
import types
from pathlib import Path
REPO_ROOT = Path(sys.argv[1])
CC_PATH = REPO_ROOT / "custom_components"
NQ_PATH = CC_PATH / "nestquest"
# 1. homeassistant must not be imported before we touch core.
assert "homeassistant" not in sys.modules, (
"homeassistant already in sys.modules before core import — "
"the subprocess is not a clean no-HA process"
)
# 2. Stub the parent packages WITHOUT running their __init__.py.
# custom_components is a namespace package; nestquest's __init__
# imports homeassistant, so we install a bare ModuleType whose
# __path__ points at the real directory instead of executing it.
cc = types.ModuleType("custom_components")
cc.__path__ = [str(CC_PATH)]
sys.modules["custom_components"] = cc
nq = types.ModuleType("custom_components.nestquest")
nq.__path__ = [str(NQ_PATH)]
nq.__package__ = "custom_components.nestquest"
sys.modules["custom_components.nestquest"] = nq
cc.nestquest = nq
# 3. Import the core modules directly from the bundle.
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.core.children import create_child
from custom_components.nestquest.core.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.core.materialize import materialize
from custom_components.nestquest.core.completion import (
complete_instance,
instance_state,
)
from custom_components.nestquest.core.recurrence import (
RuleType,
ScheduleRule,
)
from custom_components.nestquest.core.dao_instances import (
QuestInstancesDao,
)
# 4. core must not have pulled in homeassistant.
assert "homeassistant" not in sys.modules, (
"homeassistant imported by core — the core bundle is no longer "
"Home-Assistant-free"
)
async def _executor(fn, *args, **kwargs):
return await asyncio.to_thread(fn, *args, **kwargs)
async def main() -> None:
db_path = Path(tempfile.mkdtemp(prefix="nq-noha-smoke-")) / "nestquest.db"
database = NestQuestDatabase(_executor)
await database.open(str(db_path))
try:
await apply_migrations(database)
# Two children.
child_a = await create_child(database, "Ada")
child_b = await create_child(database, "Bee")
# One quest definition: daily recurrence, one morning window.
today = datetime.date.today()
start_iso = today.isoformat()
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=1, start_date=start_iso
)
created = await create_quest_definition(
database,
"Brush teeth",
rule,
[child_a.id, child_b.id],
["morning"],
)
assert created.definition.id is not None
# Materialize over a 28-day window (inclusive of both ends).
end_iso = (today + datetime.timedelta(days=27)).isoformat()
count = await materialize(database, start_iso, end_iso, today=today)
assert count > 0, (
f"materialize produced no instances over the 28-day "
f"window (count={count})"
)
# Complete one instance for child_a on the start date.
instances = QuestInstancesDao(database)
rows = await instances.list_by_child_and_date(
child_a.id, start_iso
)
assert rows, (
"no materialized instance for child_a on the start date"
)
instance = rows[0]
result = await complete_instance(
database,
instance.id,
actor_source="panel",
actor_child_id=child_a.id,
today=today,
)
assert result == "done", (
f"complete_instance returned {result!r}, expected 'done'"
)
state = await instance_state(database, instance.id, today=today)
assert state == "done", (
f"instance_state returned {state!r}, expected 'done'"
)
finally:
await database.close()
print("NO-HA SMOKE TEST OK")
asyncio.run(main())
"""
)
def test_core_runs_end_to_end_without_homeassistant() -> None:
"""Run the no-HA smoke test in a subprocess and assert its marker.
The subprocess is a genuine no-HA process: the suite's conftest
stubs are not present there, and ``homeassistant`` is not installed
in this venv. If any core module ever grows a ``homeassistant``
import (directly or via the parent package ``__init__``), the
subprocess exits non-zero with a ``ModuleNotFoundError`` and this
test fails with the subprocess's stderr.
"""
result = subprocess.run(
[sys.executable, "-c", _SCRIPT, str(_REPO_ROOT)],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
raise AssertionError(
f"no-HA smoke subprocess exited {result.returncode}.\n"
f"--- stdout ---\n{result.stdout}\n"
f"--- stderr ---\n{result.stderr}"
)
assert SUCCESS_MARKER in result.stdout, (
f"success marker {SUCCESS_MARKER!r} not printed by subprocess.\n"
f"--- stdout ---\n{result.stdout}\n"
f"--- stderr ---\n{result.stderr}"
)
+912
View File
@@ -0,0 +1,912 @@
"""Tests for core/settings.py: the explicit, HA-free settings object.
Covers the contracts from task 4dd8f870 plus the review fixes:
- ``NestQuestSettings.from_options`` applies defaults when keys are
absent and rejects invalid horizon/rollover values; ``from_options``
is strict (raises), while ``from_options_resilient`` falls back PER
FIELD so a malformed sibling cannot reset horizon_days/rollover.
- An AST scan asserts no module under
``custom_components/nestquest/core/`` imports ``homeassistant``,
names ``hass``/``config_entry``, reads a ``.config``/``.options``/
``.data`` attribute, or dynamically reads them via ``getattr``/
``hasattr``. (Docstring mentions are allowed.) A negative-control
test feeds the scanner each bypass shape and asserts it flags them.
- A settings object constructed with NON-default ``horizon_days`` and
``day_rollover_time`` drives a materialization whose resulting window
matches the non-default horizon — via BOTH the core ``materialize``
path and the INTEGRATION path (``_run_horizon_materialization`` +
``_register_day_rollover_listener``).
"""
from __future__ import annotations
import ast
import datetime
from dataclasses import FrozenInstanceError
from pathlib import Path
from typing import Any
import pytest
import custom_components.nestquest.core as _core_mod
from conftest import freeze_nestquest_clock
from custom_components.nestquest.const import (
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_AFTERNOON_REMINDER_TIME,
CONF_CELEBRATION_ENABLED,
CONF_DAY_ROLLOVER_TIME,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_END_OF_DAY_REPORT_TIME,
CONF_HORIZON_DAYS,
CONF_MORNING_SUMMARY_ENABLED,
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME,
)
from custom_components.nestquest.core.settings import NestQuestSettings
# ---------------------------------------------------------------------------
# from_options: defaults + validation.
# ---------------------------------------------------------------------------
def test_from_options_none_yields_all_defaults() -> None:
"""A None mapping yields a settings object with every default applied."""
s = NestQuestSettings.from_options(None)
assert s.horizon_days == DEFAULT_HORIZON_DAYS
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
assert s.notify_target == ""
assert s.morning_summary_time == DEFAULT_MORNING_SUMMARY_TIME
assert s.afternoon_reminder_time == DEFAULT_AFTERNOON_REMINDER_TIME
assert s.end_of_day_report_time == DEFAULT_END_OF_DAY_REPORT_TIME
assert s.morning_summary_enabled is DEFAULT_AUTOMATION_ENABLED
assert s.afternoon_reminder_enabled is DEFAULT_AUTOMATION_ENABLED
assert s.end_of_day_report_enabled is DEFAULT_AUTOMATION_ENABLED
assert s.celebration_enabled is DEFAULT_AUTOMATION_ENABLED
def test_from_options_empty_mapping_yields_all_defaults() -> None:
"""An empty mapping also yields every default (the fresh-install case)."""
s = NestQuestSettings.from_options({})
assert s.horizon_days == DEFAULT_HORIZON_DAYS
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
def test_from_options_reads_keys() -> None:
"""Each CONF_* key is read into the matching field."""
opts = {
CONF_HORIZON_DAYS: 21,
CONF_DAY_ROLLOVER_TIME: "03:30",
CONF_NOTIFY_TARGET: "notify.mobile_app_x",
CONF_MORNING_SUMMARY_TIME: "07:15",
CONF_AFTERNOON_REMINDER_TIME: "14:45",
CONF_END_OF_DAY_REPORT_TIME: "21:30",
CONF_MORNING_SUMMARY_ENABLED: False,
CONF_AFTERNOON_REMINDER_ENABLED: False,
CONF_END_OF_DAY_REPORT_ENABLED: False,
CONF_CELEBRATION_ENABLED: False,
}
s = NestQuestSettings.from_options(opts)
assert s.horizon_days == 21
assert s.day_rollover_time == "03:30"
assert s.notify_target == "notify.mobile_app_x"
assert s.morning_summary_time == "07:15"
assert s.afternoon_reminder_time == "14:45"
assert s.end_of_day_report_time == "21:30"
assert s.morning_summary_enabled is False
assert s.afternoon_reminder_enabled is False
assert s.end_of_day_report_enabled is False
assert s.celebration_enabled is False
def test_from_options_does_not_mutate_caller_mapping() -> None:
"""from_options consumes the mapping read-only."""
opts: dict[str, Any] = {CONF_HORIZON_DAYS: 9}
snapshot = dict(opts)
_ = NestQuestSettings.from_options(opts)
assert opts == snapshot
@pytest.mark.parametrize(
"value",
[True, False, "5", 0, -3, 1.5],
)
def test_from_options_rejects_invalid_horizon(value: Any) -> None:
"""A malformed horizon_days is rejected.
A key that is ABSENT (or present-but-None) falls back to the
default — see :func:`test_from_options_horizon_absent_falls_back_to_default`
and the None case below; any other invalid value raises, matching
the options flow's strict validation.
"""
with pytest.raises(ValueError, match="horizon_days"):
NestQuestSettings.from_options({CONF_HORIZON_DAYS: value})
def test_from_options_horizon_absent_falls_back_to_default() -> None:
"""An absent (or None) horizon_days key falls back to the default."""
assert NestQuestSettings.from_options({}).horizon_days == DEFAULT_HORIZON_DAYS
assert (
NestQuestSettings.from_options(
{CONF_HORIZON_DAYS: None}
).horizon_days
== DEFAULT_HORIZON_DAYS
)
def test_from_options_horizon_validates_and_defaults() -> None:
"""from_options returns the configured horizon, defaulting when absent/None.
A valid horizon is read; an absent or None key falls back to the
default; and a malformed value RAISES (the integration's
``from_options_resilient`` handles the per-field fall-back at setup,
not this strict path).
"""
assert NestQuestSettings.from_options({CONF_HORIZON_DAYS: 5}).horizon_days == 5
assert (
NestQuestSettings.from_options({}).horizon_days == DEFAULT_HORIZON_DAYS
)
assert (
NestQuestSettings.from_options(
{CONF_HORIZON_DAYS: None}
).horizon_days
== DEFAULT_HORIZON_DAYS
)
for bad in (True, "5", 0):
with pytest.raises(ValueError):
NestQuestSettings.from_options({CONF_HORIZON_DAYS: bad})
@pytest.mark.parametrize(
"value",
["25:00", "00:60", "24:00", "9:5", "abc", "07:00:00", 700, ""],
)
def test_from_options_rejects_invalid_rollover(value: Any) -> None:
"""A malformed day_rollover_time is rejected.
A key that is ABSENT (or present-but-None) falls back to the
default — a present-but-empty-string or non-HH:MM value raises,
matching the options flow's strict validation.
"""
with pytest.raises(ValueError):
NestQuestSettings.from_options({CONF_DAY_ROLLOVER_TIME: value})
def test_from_options_rollover_absent_falls_back_to_default() -> None:
"""An absent (or None) day_rollover_time key falls back to the default."""
assert (
NestQuestSettings.from_options({}).day_rollover_time
== DEFAULT_DAY_ROLLOVER_TIME
)
assert (
NestQuestSettings.from_options(
{CONF_DAY_ROLLOVER_TIME: None}
).day_rollover_time
== DEFAULT_DAY_ROLLOVER_TIME
)
@pytest.mark.parametrize(
"key",
[
CONF_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME,
],
)
def test_from_options_rejects_invalid_notification_time(key: str) -> None:
"""The three notification times are validated as strict HH:MM."""
with pytest.raises(ValueError):
NestQuestSettings.from_options({key: "25:99"})
@pytest.mark.parametrize(
"key",
[
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
],
)
def test_from_options_rejects_non_bool_toggle(key: str) -> None:
"""Each toggle must be a real bool (no string coercion)."""
with pytest.raises(ValueError):
NestQuestSettings.from_options({key: "yes"})
def test_from_options_rejects_non_string_notify_target() -> None:
"""notify_target must be a string (or absent)."""
with pytest.raises(ValueError):
NestQuestSettings.from_options({CONF_NOTIFY_TARGET: 123})
def test_from_options_strips_notify_target() -> None:
"""A padded notify target is stripped; empty stays empty."""
s = NestQuestSettings.from_options({CONF_NOTIFY_TARGET: " notify.x "})
assert s.notify_target == "notify.x"
s_empty = NestQuestSettings.from_options({CONF_NOTIFY_TARGET: " "})
assert s_empty.notify_target == ""
# ---------------------------------------------------------------------------
# from_options_resilient: per-field fallback.
# ---------------------------------------------------------------------------
def test_from_options_resilient_none_yields_all_defaults() -> None:
"""A None mapping yields the all-defaults settings (no warnings)."""
s = NestQuestSettings.from_options_resilient(None)
assert s.horizon_days == DEFAULT_HORIZON_DAYS
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
def test_from_options_resilient_reads_valid_keys() -> None:
"""Valid keys are read exactly as from_options would read them."""
s = NestQuestSettings.from_options_resilient(
{CONF_HORIZON_DAYS: 3, CONF_DAY_ROLLOVER_TIME: "03:30"}
)
assert s.horizon_days == 3
assert s.day_rollover_time == "03:30"
def test_from_options_resilient_keeps_horizon_when_sibling_is_bad(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A malformed SIBLING field cannot reset horizon_days or day_rollover_time.
The old all-or-nothing fallback reset EVERY field when from_options
raised for ANY field: ``{horizon_days: 3, morning_summary_time:
'25:00'}`` yielded horizon 14 (was 3). The resilient builder falls
back PER FIELD, so horizon_days=3 and day_rollover_time='03:30'
survive a malformed morning_summary_time, and the bad field alone
falls back to its default with a warning naming it.
"""
s = NestQuestSettings.from_options_resilient(
{
CONF_HORIZON_DAYS: 3,
CONF_DAY_ROLLOVER_TIME: "03:30",
CONF_MORNING_SUMMARY_TIME: "25:00",
}
)
assert s.horizon_days == 3
assert s.day_rollover_time == "03:30"
# The malformed sibling fell back to its default.
assert s.morning_summary_time == DEFAULT_MORNING_SUMMARY_TIME
# A warning was logged naming the bad field.
assert any(
"morning_summary_time" in record.getMessage()
and record.levelname == "WARNING"
for record in caplog.records
)
def test_from_options_resilient_bad_horizon_falls_back_with_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A malformed horizon_days itself falls back to the default + warning."""
s = NestQuestSettings.from_options_resilient({CONF_HORIZON_DAYS: True})
assert s.horizon_days == DEFAULT_HORIZON_DAYS
assert any(
"horizon_days" in record.getMessage() and record.levelname == "WARNING"
for record in caplog.records
)
def test_from_options_resilient_bad_rollover_falls_back_with_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A malformed day_rollover_time falls back to the default + warning."""
s = NestQuestSettings.from_options_resilient(
{CONF_DAY_ROLLOVER_TIME: "99:99"}
)
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
assert any(
"day_rollover_time" in record.getMessage()
and record.levelname == "WARNING"
for record in caplog.records
)
def test_from_options_resilient_none_value_is_not_a_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A None value maps to the default silently (it is absent, not malformed)."""
s = NestQuestSettings.from_options_resilient(
{CONF_HORIZON_DAYS: None, CONF_MORNING_SUMMARY_TIME: None}
)
assert s.horizon_days == DEFAULT_HORIZON_DAYS
assert s.morning_summary_time == DEFAULT_MORNING_SUMMARY_TIME
assert not [r for r in caplog.records if r.levelname == "WARNING"]
# ---------------------------------------------------------------------------
# Construction + frozenness.
# ---------------------------------------------------------------------------
def test_direct_construction_validates() -> None:
"""Hand-built instances validate the same way from_options does."""
with pytest.raises(ValueError):
NestQuestSettings(horizon_days=0)
with pytest.raises(ValueError):
NestQuestSettings(day_rollover_time="bad")
with pytest.raises(ValueError):
NestQuestSettings(morning_summary_enabled="yes")
@pytest.mark.parametrize(
"field,kwargs",
[
("horizon_days", {"horizon_days": None}),
("day_rollover_time", {"day_rollover_time": None}),
("notify_target", {"notify_target": None}),
("morning_summary_time", {"morning_summary_time": None}),
("afternoon_reminder_time", {"afternoon_reminder_time": None}),
("end_of_day_report_time", {"end_of_day_report_time": None}),
("morning_summary_enabled", {"morning_summary_enabled": None}),
("afternoon_reminder_enabled", {"afternoon_reminder_enabled": None}),
("end_of_day_report_enabled", {"end_of_day_report_enabled": None}),
("celebration_enabled", {"celebration_enabled": None}),
],
)
def test_direct_construction_rejects_none(field: str, kwargs: dict[str, Any]) -> None:
"""__post_init__ is STRICT: None is rejected for every field.
The None->default mapping lives ONLY in from_options (via
_option_value). A hand-built NestQuestSettings(field=None) must raise
in __post_init__ rather than constructing and later blowing up
horizon_window (timedelta(days=None) -> TypeError) or the rollover
listener.
"""
with pytest.raises(ValueError):
NestQuestSettings(**kwargs)
@pytest.mark.parametrize("value", [True, False, "5", 1.5])
def test_direct_construction_rejects_non_int_horizon(value: Any) -> None:
"""A bool or non-int horizon is rejected at construction (not bool-as-int)."""
with pytest.raises(ValueError, match="horizon_days"):
NestQuestSettings(horizon_days=value)
def test_direct_construction_strips_notify_target() -> None:
"""__post_init__ normalises notify_target the SAME way from_options does.
P3: a hand-built ``NestQuestSettings(notify_target=' x ')`` is
stripped to ``'x'`` (and a whitespace-only value to ``''``) so direct
construction behaves like the options flow; a padded value cannot
slip through __post_init__ either.
"""
assert NestQuestSettings(notify_target=" notify.x ").notify_target == "notify.x"
assert NestQuestSettings(notify_target=" ").notify_target == ""
assert NestQuestSettings(notify_target="notify.x").notify_target == "notify.x"
def test_settings_is_frozen() -> None:
"""The dataclass is frozen so a stale settings object cannot be mutated."""
s = NestQuestSettings()
with pytest.raises(FrozenInstanceError):
s.horizon_days = 99 # type: ignore[misc]
def test_day_rollover_hour_minute() -> None:
"""day_rollover_hour_minute returns the parsed (hour, minute)."""
s = NestQuestSettings(day_rollover_time="03:30")
assert s.day_rollover_hour_minute == (3, 30)
assert NestQuestSettings().day_rollover_hour_minute == (0, 0)
# ---------------------------------------------------------------------------
# horizon_window.
# ---------------------------------------------------------------------------
def test_horizon_window_pinned_today() -> None:
"""A pinned today is honored so the window is deterministic.
``today`` is REQUIRED (no host-clock default) — see
:meth:`NestQuestSettings.horizon_window`.
"""
s = NestQuestSettings(horizon_days=5)
pinned = datetime.date(2026, 3, 1)
start, end = s.horizon_window(pinned)
assert start == pinned
assert end == pinned + datetime.timedelta(days=5)
def test_horizon_window_requires_today() -> None:
"""horizon_window has no default today: omitting it raises TypeError."""
s = NestQuestSettings(horizon_days=5)
with pytest.raises(TypeError):
s.horizon_window() # type: ignore[call-arg]
def test_horizon_window_non_default_horizon() -> None:
"""A non-default horizon_days widens the window accordingly."""
pinned = datetime.date(2026, 3, 1)
short = NestQuestSettings(horizon_days=3).horizon_window(pinned)
long_ = NestQuestSettings(horizon_days=30).horizon_window(pinned)
assert short[1] == pinned + datetime.timedelta(days=3)
assert long_[1] == pinned + datetime.timedelta(days=30)
assert (long_[1] - short[1]).days == 27
# ---------------------------------------------------------------------------
# AST: no HA config reads under core/.
# ---------------------------------------------------------------------------
CORE_PKG = Path(_core_mod.__file__).parent
def _is_homeassistant(module: str) -> bool:
return module == "homeassistant" or module.startswith("homeassistant.")
#: HA-specific identifiers with no legitimate core use. ``entry`` is
#: deliberately NOT in this set: core uses ``entry`` as a domain loop
#: variable (iterating weekday_set entries in recurrence/presence/
#: quest_definitions), so forbidding the bare name would false-positive.
#: The config-entry attribute reads the bare-name check misses are
#: caught by _CONFIG_ATTRS and _FORBIDDEN_ATTRS below.
_FORBIDDEN_NAMES = frozenset({"hass", "config_entry"})
#: Attribute names that signal a HA object reference (self.hass,
#: self.entry, obj.config_entry). No core module currently uses any of
#: these as an attribute, so flagging them is false-positive-free.
_FORBIDDEN_ATTRS = frozenset({"hass", "entry", "config_entry"})
#: Config-entry attribute reads — the shapes ``hass.config``,
#: ``config_entry.options``, ``self.entry.options``, ``runtime.options``,
#: ``entry.data``. Flagging the attribute ANYWHERE (regardless of root)
#: catches ``runtime.options``, whose root name is not a forbidden
#: identifier. No core module currently reads any of these attributes.
_CONFIG_ATTRS = frozenset({"config", "options", "data"})
#: Dynamic config-entry reads via getattr/hasattr with a string argument.
_GETATTR_FUNCS = frozenset({"getattr", "hasattr"})
#: The dynamic-import function the string-based import bypass uses.
#: ``importlib.import_module("homeassistant...")`` reaches the HA package
#: without an ``import homeassistant`` statement the static import check
#: sees, so the scanner must recognise the call shape too.
_IMPORT_MODULE_FUNCS = frozenset({"import_module"})
def _is_import_module_call(node: ast.Call) -> bool:
"""True for ``importlib.import_module(...)`` or a bare ``import_module(...)``."""
func = node.func
if isinstance(func, ast.Attribute):
return func.attr in _IMPORT_MODULE_FUNCS
if isinstance(func, ast.Name):
return func.id in _IMPORT_MODULE_FUNCS
return False
def _scan_source_for_ha_config(
source: str, *, name: str = "<snippet>"
) -> list[str]:
"""Return offender strings for HA-coupling in ``source``.
Flags (each closes a bypass a root-only ``hass``/``entry`` chain
check would not catch):
- any ``import homeassistant`` / ``from homeassistant ...``;
- any ``importlib.import_module('homeassistant...')`` (a string-based
import that reaches HA without an ``import homeassistant`` statement
the static import check can see);
- any ``Name``/``arg`` whose id is ``hass`` or ``config_entry``
(HA-specific identifiers with no legitimate core use);
- any call keyword whose name is ``hass`` or ``config_entry``
(catches ``foo(hass=...)`` / ``handler(config_entry=...)`` — the
bare-Name check misses keyword arguments);
- any attribute access whose ``attr`` is ``hass``/``entry``/
``config_entry`` (catches ``self.hass``, ``self.entry``,
``obj.config_entry`` — root names a root-only check could not see);
- any attribute access whose ``attr`` is ``config``/``options``/
``data`` — these are Home Assistant config-entry attributes with no
legitimate core use (``.config`` reads ``hass.config``, ``.options``
reads ``config_entry.options``, ``.data`` reads ``config_entry.data``);
flagging the attribute ANYWHERE (regardless of root) is what catches
``runtime.options``, whose root name is not a forbidden identifier.
No core module currently reads any of these attributes;
- any ``getattr``/``hasattr`` call whose string argument is
``'config'``/``'options'``/``'data'`` (catches the dynamic read
``getattr(hass, 'config')`` the static attribute check cannot see).
Docstring mentions are structurally exempt: AST only sees code, and
none of these node shapes appear inside a string literal. The
existing docstring references to ``hass.config`` in ``core/events.py``
and ``core/store.py`` therefore pass.
"""
tree = ast.parse(source, filename=name)
offenders: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if _is_homeassistant(alias.name):
offenders.append(
f"{name}:{node.lineno} homeassistant import: "
f"import {alias.name}"
)
elif isinstance(node, ast.ImportFrom):
base = node.module or ""
if _is_homeassistant(base):
offenders.append(
f"{name}:{node.lineno} homeassistant import: from {base}"
)
for alias in node.names:
combined = f"{base}.{alias.name}" if base else alias.name
if _is_homeassistant(combined):
offenders.append(
f"{name}:{node.lineno} homeassistant import: "
f"from {base} import {alias.name}"
)
elif isinstance(node, ast.Name):
if node.id in _FORBIDDEN_NAMES:
offenders.append(
f"{name}:{node.lineno} forbidden identifier: {node.id!r}"
)
elif isinstance(node, ast.arg):
if node.arg in _FORBIDDEN_NAMES:
offenders.append(
f"{name}:{node.lineno} forbidden parameter: {node.arg!r}"
)
elif isinstance(node, ast.keyword):
# A call keyword like ``foo(hass=...)``: the bare-Name check
# sees the value, not the keyword's own name, so a HA object
# could be passed by keyword without tripping it. ``arg`` is
# ``None`` for ``**kwargs`` unpacking, which is not a name.
if node.arg is not None and node.arg in _FORBIDDEN_NAMES:
offenders.append(
f"{name}:{node.lineno} forbidden keyword argument: "
f"{node.arg!r}"
)
elif isinstance(node, ast.Attribute):
if node.attr in _FORBIDDEN_ATTRS:
offenders.append(
f"{name}:{node.lineno} forbidden attribute: .{node.attr}"
)
if node.attr in _CONFIG_ATTRS:
offenders.append(
f"{name}:{node.lineno} config-entry attribute read: "
f".{node.attr}"
)
elif isinstance(node, ast.Call):
if _is_import_module_call(node):
# The first positional argument is the module string; a
# constant ``'homeassistant...'`` is the bypass shape.
for arg in node.args:
if (
isinstance(arg, ast.Constant)
and isinstance(arg.value, str)
and _is_homeassistant(arg.value)
):
offenders.append(
f"{name}:{node.lineno} dynamic homeassistant "
f"import: import_module({arg.value!r})"
)
func = node.func
if isinstance(func, ast.Name) and func.id in _GETATTR_FUNCS:
for arg in node.args:
if (
isinstance(arg, ast.Constant)
and isinstance(arg.value, str)
and arg.value in _CONFIG_ATTRS
):
offenders.append(
f"{name}:{node.lineno} {func.id}() dynamic "
f"config-entry read: {arg.value!r}"
)
return offenders
def _scan_module_for_ha_config(path: Path) -> list[str]:
"""Scan a core module file for HA-coupling (file-backed scan)."""
return _scan_source_for_ha_config(
path.read_text(encoding="utf-8"), name=path.name
)
def test_core_modules_have_no_ha_config_reads() -> None:
"""No core module imports homeassistant or reads HA config / entry options.
The scan forbids the HA-specific identifiers ``hass``/``config_entry``,
the HA-object attributes ``.hass``/``.entry``/``.config_entry``, the
config-entry attribute reads ``.config``/``.options``/``.data``, and
dynamic ``getattr``/``hasattr`` reads of them — so a chain rooted at
``self`` or ``runtime`` (whose root name is not a forbidden
identifier) is caught. ``.config``/``.options``/``.data`` are banned
because they are Home Assistant config-entry attributes with no
legitimate core use: core receives the resolved values via the
explicit :class:`NestQuestSettings` object the integration builds
from ``entry.options``, so any read of those attributes in ``core/``
is a config-entry coupling the settings object exists to remove.
Docstring mentions pass: AST only sees code.
"""
py_files = sorted(CORE_PKG.rglob("*.py"))
assert py_files, "core package not found"
all_offenders: list[str] = []
for py_file in py_files:
all_offenders.extend(_scan_module_for_ha_config(py_file))
assert all_offenders == [], (
"core package reads HA config / entry options:\n"
+ "\n".join(all_offenders)
)
def test_ast_scan_flags_each_bypass_shape() -> None:
"""Negative control: the scanner flags each bypass a root-only check misses.
A root-only scan flags chains rooted at a literal name ``hass``/
``entry``. Each snippet below is a shape such a scan would miss;
this scanner must flag every one.
"""
snippets: dict[str, str] = {
"self.hass.config": "x = self.hass.config\n",
"config_entry.options": "x = config_entry.options\n",
"self.entry.options": "x = self.entry.options\n",
"getattr(hass,'config')": "x = getattr(hass, 'config')\n",
"runtime.options": "x = runtime.options\n",
"hasattr(obj,'data')": "x = hasattr(obj, 'data')\n",
"importlib.import_module('homeassistant')": (
"import importlib\nimportlib.import_module('homeassistant')\n"
),
"import_module('homeassistant.helpers')": (
"from importlib import import_module\n"
"import_module('homeassistant.helpers')\n"
),
"handler(hass=...)": "handler(hass=obj)\n",
"handler(config_entry=...)": "handler(config_entry=obj)\n",
}
for label, src in snippets.items():
offenders = _scan_source_for_ha_config(src, name=label)
assert offenders, f"scanner missed {label!r}: {src!r}"
def test_ast_scan_allows_non_homeassistant_import_module() -> None:
"""A dynamic import of a NON-homeassistant module is not flagged.
``importlib.import_module`` is a legitimate way to load a non-HA
module lazily; only a ``homeassistant...`` target is the bypass shape.
"""
src = (
"import importlib\n"
"m = importlib.import_module('custom_components.nestquest.core')\n"
)
assert _scan_source_for_ha_config(src, name="non-ha-import") == []
def test_ast_scan_allows_non_forbidden_keyword_arguments() -> None:
"""A call with non-forbidden keyword arguments is not flagged.
Only ``hass=``/``config_entry=`` are forbidden as keyword names; an
unrelated keyword like ``hass_data=`` or ``entry_id=`` must not
false-positive (and ``**kwargs`` unpacking has ``arg=None``).
"""
src = (
"foo(entry_id=1, name='x')\n"
"bar(**kwargs)\n"
)
assert _scan_source_for_ha_config(src, name="non-forbidden-kwargs") == []
def test_ast_scan_allows_legitimate_entry_loop_var() -> None:
"""A bare ``entry`` loop variable (a domain concept) is NOT flagged.
``entry`` is used in core as a loop variable over weekday_set entries
(recurrence.py, presence.py, quest_definitions.py) — not a HA config
entry. Forbidding the bare name would false-positive on those, so
only ``hass``/``config_entry`` are forbidden as bare names; the
config-entry reads via ``entry.options``/``entry.data`` are caught by
the ``_CONFIG_ATTRS`` attribute check instead.
"""
offenders = _scan_source_for_ha_config(
"for entry in items:\n pass\n", name="loop"
)
assert offenders == [], offenders
def test_ast_scan_allows_clean_core_shape() -> None:
"""A clean snippet with no HA coupling is not flagged (no false positives)."""
src = "opts = {'a': 1}\nx = opts.get('a')\nfor entry in items:\n pass\n"
offenders = _scan_source_for_ha_config(src, name="clean")
assert offenders == [], offenders
def test_settings_module_lives_under_core() -> None:
"""settings.py is part of the core package (not the integration root)."""
assert (CORE_PKG / "settings.py").is_file()
# And it is importable as a core submodule.
import custom_components.nestquest.core.settings as mod
assert hasattr(mod, "NestQuestSettings")
assert isinstance(mod.NestQuestSettings, type)
def test_settings_module_has_no_ha_imports() -> None:
"""settings.py specifically carries no homeassistant import or config read."""
offenders = _scan_module_for_ha_config(CORE_PKG / "settings.py")
assert offenders == [], offenders
# ---------------------------------------------------------------------------
# Materialization over a non-default horizon: core path + integration path.
# ---------------------------------------------------------------------------
async def _prepare(path, executor):
"""Open and migrate a database backed by ``executor`` (executor-only DB access)."""
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
database = NestQuestDatabase(executor)
await database.open(path)
await apply_migrations(database)
return database
async def test_materialize_over_non_default_horizon(hass, tmp_path) -> None:
"""A settings object with a non-default horizon drives the matching window.
Constructs NestQuestSettings with horizon_days=3 and day_rollover_time
'03:30' (both non-default), builds the window via horizon_window, and
drives the CORE ``materialize`` over it; asserts the resulting instance
rows land exactly on the [today, today+3] window and NOT beyond it.
"""
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_instances import (
QuestInstancesDao,
)
from custom_components.nestquest.core.materialize import materialize
from custom_components.nestquest.core.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.core.recurrence import (
RuleType,
ScheduleRule,
)
database = await _prepare(tmp_path / "nq.db", hass.async_add_executor_job)
try:
settings = NestQuestSettings(
horizon_days=3, day_rollover_time="03:30"
)
assert settings.horizon_days == 3
assert settings.day_rollover_time == "03:30"
today = datetime.date(2026, 3, 1)
start, end = settings.horizon_window(today)
assert start == today
assert end == today + datetime.timedelta(days=3)
NOW = "2026-03-01T12:00:00+00:00"
child = await ChildrenDao(database).create("Ada", NOW)
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(
rule_type=RuleType.DAILY, start_date=today.isoformat()
),
[child.id],
["morning"],
)
count = await materialize(
database,
start.isoformat(),
end.isoformat(),
today=today,
)
# 4 days (today..today+3 inclusive), one instance per day.
assert count == 4
records = await QuestInstancesDao(database).list_by_date_range(
child.id, start.isoformat(), end.isoformat()
)
dates = sorted({r.due_date for r in records})
assert dates == [
(today + datetime.timedelta(days=i)).isoformat()
for i in range(4)
]
# Beyond the configured horizon: no instances.
beyond = (end + datetime.timedelta(days=1)).isoformat()
far = (end + datetime.timedelta(days=10)).isoformat()
assert await QuestInstancesDao(database).list_by_date_range(
child.id, beyond, far
) == []
finally:
await database.close()
async def test_integration_path_threads_settings(
hass, tmp_path, monkeypatch
) -> None:
"""The integration path threads ``settings`` through horizon_window + rollover.
``_run_horizon_materialization`` calls
``settings.horizon_window(today)`` (so the helper is real and shared,
not recomputed inline), and ``_register_day_rollover_listener``
registers at ``settings.day_rollover_hour_minute``. This runs the
INTEGRATION path with ``horizon_days=3`` and
``day_rollover_time='03:30'`` and asserts the resulting instance
window matches [today, today+3] AND the registered hour/minute are 3/30.
"""
import custom_components.nestquest as nestquest
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_instances import (
QuestInstancesDao,
)
from custom_components.nestquest.core.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.core.recurrence import (
RuleType,
ScheduleRule,
)
# Freeze the integration clock so "today" is deterministic (the
# hass fixture's config.time_zone is UTC, so 2026-03-01 12:00 UTC
# localizes to 2026-03-01).
freeze_nestquest_clock(
monkeypatch, datetime.datetime(2026, 3, 1, 12, 0, tzinfo=datetime.timezone.utc)
)
database = await _prepare(tmp_path / "nq.db", hass.async_add_executor_job)
try:
settings = NestQuestSettings(horizon_days=3, day_rollover_time="03:30")
today = datetime.date(2026, 3, 1)
NOW = "2026-03-01T12:00:00+00:00"
child = await ChildrenDao(database).create("Ada", NOW)
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(
rule_type=RuleType.DAILY, start_date=today.isoformat()
),
[child.id],
["morning"],
)
# Integration path: the window comes from settings.horizon_window.
await nestquest._run_horizon_materialization(hass, database, settings)
end = today + datetime.timedelta(days=3)
records = await QuestInstancesDao(database).list_by_date_range(
child.id, today.isoformat(), end.isoformat()
)
dates = sorted({r.due_date for r in records})
assert dates == [
(today + datetime.timedelta(days=i)).isoformat()
for i in range(4)
]
beyond = (end + datetime.timedelta(days=1)).isoformat()
far = (end + datetime.timedelta(days=10)).isoformat()
assert await QuestInstancesDao(database).list_by_date_range(
child.id, beyond, far
) == []
# The rollover listener registers at the configured hour/minute.
remove = nestquest._register_day_rollover_listener(
hass, database, settings
)
try:
reg = hass.time_change.registrations[-1]
assert reg["hour"] == 3
assert reg["minute"] == 30
finally:
remove()
finally:
await database.close()
+9 -9
View File
@@ -30,7 +30,7 @@ def _make_hass_mock():
def _open_db(path) -> NestQuestDatabase:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
_run(database.open(path))
_run(apply_migrations(database))
return database
@@ -319,7 +319,7 @@ def test_reorder_concurrent_calls_serialize(tmp_path) -> None:
RuntimeError.
"""
async def _main() -> None:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / "reorder-concurrent.db")
try:
await apply_migrations(database)
@@ -377,7 +377,7 @@ def test_admin_add_concurrent_calls_are_idempotent(tmp_path) -> None:
'see no row' and one of them raise IntegrityError.
"""
async def _main() -> None:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / "admin-race.db")
try:
await apply_migrations(database)
@@ -487,17 +487,17 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/dao_children.py", # the DAO itself
"custom_components/nestquest/dao_rules.py", # validates child
"custom_components/nestquest/core/dao_children.py", # the DAO itself
"custom_components/nestquest/core/dao_rules.py", # validates child
# activeness on assignment (FK to children, done-condition
# 'validates that the assigned child is active')
"custom_components/nestquest/dao_presence.py", # validates the
"custom_components/nestquest/core/dao_presence.py", # validates the
# schedule/override child exists before writing (same FK shape
# as dao_rules: one existence SELECT per write)
"custom_components/nestquest/dao_instances.py", # validates the
"custom_components/nestquest/core/dao_instances.py", # validates the
# instance/event child exists before writing (same FK shape)
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"custom_components/nestquest/core/schema.py", # declares the DDL
"custom_components/nestquest/core/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_children.py", # this file, scanned separately
+7 -7
View File
@@ -71,7 +71,7 @@ D20 = _future_day(20)
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
children = ChildrenDao(database)
@@ -352,7 +352,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(
"custom_components.nestquest.dao_instances.datetime",
"custom_components.nestquest.core.dao_instances.datetime",
_ShiftingModule(),
)
try:
@@ -362,7 +362,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
# the lock, the IN-LOCK re-check sees tomorrow and must
# reject the insert — the stale call-date pre-check alone
# would have let it through.
import custom_components.nestquest.dao_instances as mod
import custom_components.nestquest.core.dao_instances as mod
async def _lock_holder():
# Hold the CONNECTION lock across a transaction until
@@ -402,7 +402,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
return None
async def _main():
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / "upsert-rollover.db")
try:
await apply_migrations(database)
@@ -1030,8 +1030,8 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None:
# INSERT statements are stripped before scanning so only genuine
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
insert_exempt = {
"custom_components/nestquest/dao_instances.py",
"custom_components/nestquest/migrations.py", # the rebuild
"custom_components/nestquest/core/dao_instances.py",
"custom_components/nestquest/core/migrations.py", # the rebuild
# migrations re-create the table via INSERT..SELECT (append-
# order preserved); UPDATE/DELETE stays forbidden everywhere
"tests/test_schema.py",
@@ -1141,7 +1141,7 @@ def test_completion_events_dao_instance_has_no_mutation_capability(
def test_append_concurrent_events_serialize(tmp_path) -> None:
"""Concurrent appends for the same instance all land, in order."""
async def _main():
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / "append-race.db")
try:
await apply_migrations(database)
+2 -2
View File
@@ -118,7 +118,7 @@ class World:
async def _world(path) -> World:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
world = World(database)
@@ -441,7 +441,7 @@ def test_completion_events_absolutely_no_mutation_path() -> None:
)
offenders = [
py.name
for py in sorted(package.glob("*.py"))
for py in sorted(package.rglob("*.py"))
if py.name != "__pycache__" and mutation.search(py.read_text())
]
assert offenders == []
+6 -6
View File
@@ -33,7 +33,7 @@ NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
schedules = PresenceSchedulesDao(database)
@@ -196,7 +196,7 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
return fn(*args)
hass.async_add_executor_job = _gated_executor
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "upsert-race.db")
try:
await apply_migrations(database)
@@ -553,9 +553,9 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/dao_presence.py", # this DAO
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"custom_components/nestquest/core/dao_presence.py", # this DAO
"custom_components/nestquest/core/schema.py", # declares the DDL
"custom_components/nestquest/core/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_presence.py", # this file, scanned separately
@@ -766,7 +766,7 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None:
return fn(*args)
hass.async_add_executor_job = _gated_executor
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "override-race.db")
try:
await apply_migrations(database)
+6 -6
View File
@@ -42,7 +42,7 @@ NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
rules = ScheduleRulesDao(database)
@@ -325,7 +325,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
create's validation pauses mid-flight before the delete starts.
"""
async def _main(order: str):
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_path / f"rule-delete-race-{order}.db")
try:
await apply_migrations(database)
@@ -1218,12 +1218,12 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/dao_rules.py", # this DAO
"custom_components/nestquest/dao_instances.py", # validates the
"custom_components/nestquest/core/dao_rules.py", # this DAO
"custom_components/nestquest/core/dao_instances.py", # validates the
# definition exists + fetches its assignee before generating an
# instance (the instance must snapshot the definition's child)
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"custom_components/nestquest/core/schema.py", # declares the DDL
"custom_components/nestquest/core/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_rules.py", # this file, scanned separately
+28 -26
View File
@@ -11,9 +11,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from conftest import make_hass
from conftest import executor_for, make_hass
from custom_components.nestquest import db as db_module
import custom_components.nestquest.core.db as db_module
from custom_components.nestquest.db import NestQuestDatabase
@@ -29,7 +29,7 @@ def _make_hass_mock():
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
_run(database.open(path))
return database, hass
@@ -55,7 +55,7 @@ def test_open_enables_foreign_key_enforcement(tmp_path) -> None:
def test_open_delegates_connection_to_executor(tmp_path) -> None:
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
_run(database.open(tmp_path / "exec.db"))
first_call = hass.async_add_executor_job.call_args_list[0]
assert callable(first_call.args[0])
@@ -65,7 +65,7 @@ def test_open_delegates_connection_to_executor(tmp_path) -> None:
def test_open_twice_returns_same_wrapper_without_second_connect(tmp_path) -> None:
path = tmp_path / "once.db"
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
first = _run(database.open(path))
second = _run(database.open(path))
assert first is database
@@ -111,7 +111,7 @@ def test_every_method_routes_sqlite3_through_executor(tmp_path) -> None:
async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
"""The executor surface is awaited per wrapper call on a live loop."""
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "loop.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
@@ -129,7 +129,7 @@ async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
async def test_public_methods_return_only_plain_data(tmp_path) -> None:
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "plain.db")
result = await database.execute(
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
@@ -156,7 +156,7 @@ async def test_public_methods_return_only_plain_data(tmp_path) -> None:
async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> None:
"""With the conftest hass (real run_in_executor), queries still succeed."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "real.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
assert await database.fetch_one("SELECT 1") == (1,)
@@ -210,7 +210,7 @@ async def test_real_executor_never_touches_loop_thread(tmp_path, monkeypatch) ->
monkeypatch.setattr(db_module.sqlite3, "connect", _traced_connect)
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "threads.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
@@ -250,7 +250,9 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
"""Every sqlite3.* call in db.py sits inside an executor-delegating function.
A function is executor-delegating when it (or, for nested closures, any of
its enclosing functions) passes work to ``hass.async_add_executor_job``.
its enclosing functions) passes work to the wrapper's ``self._executor``
callable (the Home-Assistant-free contract that mirrors
``hass.async_add_executor_job``).
"""
source = Path(db_module.__file__).read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(db_module.__file__))
@@ -262,7 +264,7 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
and any(
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "async_add_executor_job"
and sub.func.attr == "_executor"
for sub in ast.walk(node)
)
}
@@ -375,7 +377,7 @@ async def test_commit_failure_rolls_back_and_next_transaction_works(
must run cleanly.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "deferred.db")
await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
await database.execute(
@@ -416,7 +418,7 @@ async def test_cancel_during_open_closes_abandoned_connection(tmp_path) -> None:
propagates, and a follow-up open() must succeed cleanly.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(executor_for(hass))
open_started = asyncio.Event()
release_open = asyncio.Event()
@@ -481,7 +483,7 @@ async def test_cancel_during_begin_settles_then_rolls_back(tmp_path) -> None:
clear its state, and stay usable for the next transaction.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(executor_for(hass))
await database.open(tmp_path / "cancel-begin.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
@@ -543,7 +545,7 @@ async def test_cancel_during_commit_settles_then_propagates_cancellation(
transaction, and the wrapper must stay usable.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(executor_for(hass))
await database.open(tmp_path / "cancel-commit.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
@@ -603,7 +605,7 @@ async def test_cancel_during_in_transaction_execute_settles_then_rolls_back(
not persist.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(executor_for(hass))
await database.open(tmp_path / "cancel-execute.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
@@ -666,7 +668,7 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None
commit failure behind the rollback failure.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(executor_for(hass))
await database.open(tmp_path / "both-fail.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
@@ -698,14 +700,14 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None
async def test_transaction_persists_across_connections(tmp_path) -> None:
"""A committed transaction is visible to a brand-new connection."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "persist.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
await database.close()
other = NestQuestDatabase(hass)
other = NestQuestDatabase(hass.async_add_executor_job)
await other.open(tmp_path / "persist.db")
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
await other.close()
@@ -726,7 +728,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
while a transaction is open queue behind it on the wrapper lock.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "serial.db")
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
tx_rounds, writers, writer_rounds = 6, 5, 8
@@ -766,7 +768,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> None:
"""A second transaction caller is rejected with a clear error."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "reject.db")
await database.execute("CREATE TABLE t (id INTEGER)")
@@ -791,7 +793,7 @@ async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> Non
async def test_transaction_blocks_other_statements_until_commit(tmp_path) -> None:
"""A statement issued during an open transaction queues behind it."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "block.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
@@ -830,7 +832,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error(
) -> None:
"""A second concurrent transaction() gets a clear RuntimeError."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "concurrent-tx.db")
await database.execute("CREATE TABLE t (id INTEGER)")
@@ -853,7 +855,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error(
async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
"""close() serializes: an open transaction finishes before shutdown."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "closerace.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
@@ -875,7 +877,7 @@ async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
await asyncio.gather(txn_task, close_task)
assert database.connected is False
witness = NestQuestDatabase(hass)
witness = NestQuestDatabase(hass.async_add_executor_job)
await witness.open(tmp_path / "closerace.db")
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
await witness.close()
@@ -886,7 +888,7 @@ async def test_close_during_transaction_from_same_task_is_rejected(
) -> None:
"""close() inside its own transaction body raises instead of deadlocking."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "selfclose.db")
await database.execute("CREATE TABLE t (id INTEGER)")
with pytest.raises(RuntimeError, match="transaction"):
+423 -9
View File
@@ -31,7 +31,19 @@ ADMIN_CTX = {"user_id": "admin-1"}
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
async def _setup_and_seed(hass, make_entry, *, windows=("morning",)):
async def _setup_and_seed(hass, make_entry, *, windows=("morning",), seed_date=None):
"""Seed a household and materialize today's instances.
``seed_date`` pins the date instances are materialized for (and the
daily rule's ``start_date``); it defaults to the host's
``datetime.date.today()``. Callers that need to test the day-complete
rule against a non-UTC HA-local timezone pass the local zone's
``today`` here so the seeded instance's ``due_date`` matches it.
"""
import datetime
if seed_date is None:
seed_date = datetime.date.today()
entry = wire_entry_to_registry(
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
)
@@ -43,7 +55,6 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",)):
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = None
from custom_components.nestquest.children import list_children
child = (await list_children(database))[0]
@@ -51,18 +62,16 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",)):
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": __import__("datetime").date.today().isoformat()}
{"rule_type": "daily", "start_date": seed_date.isoformat()}
),
[child.id],
list(windows),
)
import datetime
today = datetime.date.today().isoformat()
end = (datetime.date.today() + datetime.timedelta(days=3)).isoformat()
await materialize(database, today, end, today=datetime.date.today())
today_iso = seed_date.isoformat()
end_iso = (seed_date + datetime.timedelta(days=3)).isoformat()
await materialize(database, today_iso, end_iso, today=seed_date)
instances = await QuestInstancesDao(database).list_by_date_range(
child.id, today, today
child.id, today_iso, today_iso
)
return entry, child, instances
@@ -377,3 +386,408 @@ async def test_forced_refreshes_serialize_no_stale_publish(
assert order == ["start", "end", "start", "end"], (
"overlapping refreshes must serialize: " + str(order)
)
# ---------------------------------------------------------------------------
# Direct core-builder unit tests (P2.2a): exact payload key sets, event
# order, and was_on_time threading. These target
# custom_components.nestquest.core.events directly, bypassing the shim,
# so a payload-shape regression is caught even if the shim masks it.
# ---------------------------------------------------------------------------
_COMPLETED_KEYS = {
"child_id",
"child_name",
"instance_id",
"quest_title",
"window",
"due_date",
"due_time",
"occurred_at",
"was_on_time",
}
_UNCOMPLETED_KEYS = {
"child_id",
"child_name",
"instance_id",
"quest_title",
"window",
"due_date",
"due_time",
"occurred_at",
}
_DAY_COMPLETE_KEYS = {
"child_id",
"child_name",
"quests_due",
"quests_completed",
"occurred_at",
}
async def test_build_quest_completed_event_payload_key_set(hass, make_entry) -> None:
"""The completed-event payload carries exactly the documented keys
(no extra fields, no missing field) and threads ``was_on_time``."""
from custom_components.nestquest.core.events import build_quest_completed_event
entry, child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
event_type, payload = await build_quest_completed_event(
database, instances[0].id, was_on_time=True
)
assert event_type == EVENT_QUEST_COMPLETED
assert set(payload) == _COMPLETED_KEYS, (
f"completed payload keys drift: got {set(payload)} expected {_COMPLETED_KEYS}"
)
assert payload["was_on_time"] is True
assert payload["child_id"] == child.id
assert payload["child_name"] == "Ada"
assert payload["instance_id"] == instances[0].id
assert payload["quest_title"] == "Brush teeth"
assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"]
async def test_build_quest_completed_event_was_on_time_threads_all_shapes(
hass, make_entry
) -> None:
"""``was_on_time`` is carried verbatim — True, False, and None
(the unknown-timing shape) all round-trip into the payload."""
from custom_components.nestquest.core.events import build_quest_completed_event
entry, _child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
for wanted in (True, False, None):
_etype, payload = await build_quest_completed_event(
database, instances[0].id, was_on_time=wanted
)
assert payload["was_on_time"] is wanted, (
f"was_on_time={wanted!r} did not thread through"
)
async def test_build_quest_uncompleted_events_payload_key_set(
hass, make_entry
) -> None:
"""The uncompleted-event payload carries exactly the documented keys
(no ``was_on_time`` — that field is completion-only)."""
from custom_components.nestquest.core.events import (
build_quest_uncompleted_events,
)
entry, _child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
events = await build_quest_uncompleted_events(database, instances[0].id)
assert len(events) == 1
event_type, payload = events[0]
assert event_type == EVENT_QUEST_UNCOMPLETED
assert set(payload) == _UNCOMPLETED_KEYS, (
f"uncompleted payload keys drift: got {set(payload)} "
f"expected {_UNCOMPLETED_KEYS}"
)
assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"]
async def test_build_child_day_complete_event_payload_key_set(
hass, make_entry
) -> None:
"""The day-complete payload carries exactly the documented keys
when the child's whole day clears, and reuses the completed
event's ``occurred_at`` stamp (one transition, one timestamp)."""
import datetime
from custom_components.nestquest.core.events import (
build_child_day_complete_event,
build_quest_completed_event,
)
entry, _child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
today = datetime.date.today()
completed_type, completed_payload = await build_quest_completed_event(
database, instances[0].id, was_on_time=True
)
# Mark every today-instance done so the day clears.
from custom_components.nestquest.completion import complete_instance
for instance in instances:
await complete_instance(
database,
instance.id,
actor_source="panel",
actor_child_id=_child.id,
today=today,
)
day_event = await build_child_day_complete_event(
database, completed_payload, today=today
)
assert day_event is not None
event_type, day_payload = day_event
assert event_type == EVENT_CHILD_DAY_COMPLETE
assert set(day_payload) == _DAY_COMPLETE_KEYS, (
f"day-complete payload keys drift: got {set(day_payload)} "
f"expected {_DAY_COMPLETE_KEYS}"
)
assert day_payload["occurred_at"] == completed_payload["occurred_at"], (
"day-complete must reuse the completed event's occurred_at stamp"
)
assert day_payload["quests_due"] == len(instances)
assert day_payload["quests_completed"] == len(instances)
async def test_shim_fires_completed_before_day_complete(hass, make_entry) -> None:
"""The shim fires ``nestquest_quest_completed`` BEFORE
``nestquest_child_day_complete`` (the pre-extraction ordering), so
a failure in the day-complete DB reads cannot suppress the
completed event. Asserted by bus event ORDER, not just counts."""
entry, child, instances = await _setup_and_seed(hass, make_entry)
for instance in instances:
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": child.id,
},
)
ordered = [
etype for etype, _payload in hass.bus.events
if etype in (EVENT_QUEST_COMPLETED, EVENT_CHILD_DAY_COMPLETE)
]
# The completed event must precede the day-complete event.
first_completed = ordered.index(EVENT_QUEST_COMPLETED)
first_day = ordered.index(EVENT_CHILD_DAY_COMPLETE)
assert first_completed < first_day, (
f"completed must fire before day-complete; order was {ordered}"
)
# ---------------------------------------------------------------------------
# Day-complete rule coverage (P2.2b): an explicit ``today`` proves the
# four outcomes — zero-quest day, non-today instance, partial day, and
# fully-cleared today.
# ---------------------------------------------------------------------------
async def test_day_complete_zero_quest_day_yields_no_event(
hass, make_entry
) -> None:
"""A zero-quest day never fires day-complete: when the completed
payload's ``due_date`` matches ``today`` but the child has NO
instances due that date, the builder returns ``None`` (the
all-done binary sensor's rule — a day with zero quests owed can
never be "cleared")."""
import datetime
from custom_components.nestquest.core.events import (
build_child_day_complete_event,
build_quest_completed_event,
)
entry, _child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
# A date the daily rule does NOT cover (well past the materialized
# horizon) — zero instances due this date.
zero_quest_day = datetime.date.today() + datetime.timedelta(days=365)
_etype, completed_payload = await build_quest_completed_event(
database, instances[0].id, was_on_time=True
)
# Override due_date to the zero-quest day so the builder evaluates
# it (otherwise the due_date != today short-circuit fires first).
crafted = dict(completed_payload, due_date=zero_quest_day.isoformat())
result = await build_child_day_complete_event(
database, crafted, today=zero_quest_day
)
assert result is None, (
"a zero-quest day must not yield a day-complete event"
)
async def test_day_complete_non_today_instance_yields_no_event(
hass, make_entry
) -> None:
"""Completing an instance NOT due today never evaluates the
child's day-complete for today: only the completed event fires."""
import datetime
from custom_components.nestquest.core.events import (
build_child_day_complete_event,
build_quest_completed_event,
)
entry, _child, instances = await _setup_and_seed(hass, make_entry)
database = entry.runtime_data.database
# Complete a FUTURE instance (tomorrow), pass today = today.
from custom_components.nestquest.dao_instances import QuestInstancesDao
today = datetime.date.today()
end = (today + datetime.timedelta(days=3)).isoformat()
all_instances = await QuestInstancesDao(database).list_by_date_range(
_child.id, today.isoformat(), end
)
today_ids = {i.id for i in instances}
tomorrow_instance = next(i for i in all_instances if i.id not in today_ids)
_etype, completed_payload = await build_quest_completed_event(
database, tomorrow_instance.id, was_on_time=True
)
assert completed_payload["due_date"] != today.isoformat()
result = await build_child_day_complete_event(
database, completed_payload, today=today
)
assert result is None, (
"a non-today instance must not yield a day-complete event for today"
)
async def test_day_complete_partial_day_yields_no_event(
hass, make_entry
) -> None:
"""A partial day (some quests still open) yields only the
completed event — day-complete does not fire until the WHOLE day
clears."""
import datetime
from custom_components.nestquest.core.events import (
build_child_day_complete_event,
build_quest_completed_event,
)
from custom_components.nestquest.completion import complete_instance
entry, child, instances = await _setup_and_seed(
hass, make_entry, windows=("morning", "evening")
)
assert len(instances) == 2
database = entry.runtime_data.database
today = datetime.date.today()
# Complete ONLY the morning instance — the evening one stays open.
await complete_instance(
database,
instances[0].id,
actor_source="panel",
actor_child_id=child.id,
today=today,
)
_etype, completed_payload = await build_quest_completed_event(
database, instances[0].id, was_on_time=True
)
result = await build_child_day_complete_event(
database, completed_payload, today=today
)
assert result is None, (
"a partial day (evening quest still open) must not fire day-complete"
)
async def test_day_complete_fully_cleared_today_yields_event(
hass, make_entry
) -> None:
"""A fully-cleared today (all owed quests done) yields the
completed event AND the child_day_complete event."""
import datetime
from custom_components.nestquest.core.events import (
build_child_day_complete_event,
build_quest_completed_event,
)
from custom_components.nestquest.completion import complete_instance
entry, child, instances = await _setup_and_seed(
hass, make_entry, windows=("morning", "evening")
)
assert len(instances) == 2
database = entry.runtime_data.database
today = datetime.date.today()
for instance in instances:
await complete_instance(
database,
instance.id,
actor_source="panel",
actor_child_id=child.id,
today=today,
)
# The last completion's payload drives the day-complete evaluation.
_etype, completed_payload = await build_quest_completed_event(
database, instances[-1].id, was_on_time=True
)
result = await build_child_day_complete_event(
database, completed_payload, today=today
)
assert result is not None, (
"a fully-cleared today must yield the day-complete event"
)
assert result[0] == EVENT_CHILD_DAY_COMPLETE
assert result[1]["quests_due"] == len(instances)
assert result[1]["quests_completed"] == len(instances)
# ---------------------------------------------------------------------------
# Shim timezone wiring (P2.2c): the conftest fake hass defaults
# hass.config.time_zone to UTC, so the day-complete rule's HA-local
# ``today`` wiring is otherwise untested. This test sets the zone to
# one whose current local date differs from UTC and asserts the
# day-complete rule uses THAT local date, not UTC.
# ---------------------------------------------------------------------------
async def test_shim_day_complete_uses_ha_local_timezone(
hass, make_entry
) -> None:
"""The day-complete rule reads ``hass.config.time_zone`` and
derives ``today`` in HA-local time, not UTC.
Picks a zone whose current local date differs from UTC, seeds an
instance for THAT zone's today (not UTC's today), completes it
through the service path, and asserts
``nestquest_child_day_complete`` fires — proving the local date
reached the day-complete rule. If no candidate zone has a
different date right now (only possible inside the ~1h window
around 12:00 UTC when all zones share one date), the test skips
rather than run vacuously.
"""
import datetime as _dt
from zoneinfo import ZoneInfo
utc_today = _dt.datetime.now(_dt.timezone.utc).date()
far_zone = None
far_today = None
for tz_name in (
"Pacific/Kiritimati", # +14 (UTC+14)
"Pacific/Auckland", # +12/+13
"Pacific/Chatham", # +12:45/+13:45
"Pacific/Honolulu", # -10
"America/Los_Angeles", # -8/-7
"Etc/GMT+12", # -12
"Etc/GMT-13", # +13
):
local_today = _dt.datetime.now(ZoneInfo(tz_name)).date()
if local_today != utc_today:
far_zone = tz_name
far_today = local_today
break
if far_zone is None:
import pytest
pytest.skip(
"no candidate zone has a date different from UTC right now"
)
hass.config.time_zone = far_zone
entry, child, instances = await _setup_and_seed(
hass, make_entry, seed_date=far_today
)
for instance in instances:
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": child.id,
},
)
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
assert len(day_complete) == 1, (
f"day-complete must fire under {far_zone} (local today "
f"{far_today.isoformat()}, UTC today {utc_today.isoformat()}); "
f"got {len(day_complete)} events"
)
+74 -19
View File
@@ -12,6 +12,7 @@ from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
CONF_MORNING_SUMMARY_TIME,
DEFAULT_HORIZON_DAYS,
SERVICE_REGENERATE,
DOMAIN as DOMAIN_CONST,
@@ -36,12 +37,12 @@ async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
async def test_setup_runtime_data_has_entry_id_and_options(hass, make_entry) -> None:
async def test_setup_runtime_data_has_entry_id_and_settings(hass, make_entry) -> None:
entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry)
await async_setup_entry(hass, entry)
runtime = entry.runtime_data
assert runtime.entry_id == entry.entry_id
assert runtime.options == {"horizon_days": 7}
assert runtime.settings.horizon_days == 7
assert callable(runtime.remove_update_listener)
@@ -472,7 +473,7 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
# Seed the database BEFORE setup: a restart reopens an existing file that
# already holds definitions. The startup backfill must pick them up.
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass)
pre = NestQuestDatabase(hass.async_add_executor_job)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
@@ -499,7 +500,7 @@ async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None
from custom_components.nestquest.store import async_get_db_path
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass)
pre = NestQuestDatabase(hass.async_add_executor_job)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
@@ -526,24 +527,78 @@ async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None
).list_by_date_range(child_id, beyond, beyond_end) == []
def test_configured_horizon_days_validates_and_falls_back() -> None:
"""The configured horizon is validated; a malformed value uses the default."""
import custom_components.nestquest as nestquest
async def test_setup_succeeds_with_fully_malformed_options(
hass, make_entry
) -> None:
"""A fully malformed options dict falls back per field and setup succeeds.
assert nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 5}) == 5
assert nestquest._configured_horizon_days({}) == DEFAULT_HORIZON_DAYS
assert (
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: True})
== DEFAULT_HORIZON_DAYS
``horizon_days=True``, ``day_rollover_time='99:99'`` and
``morning_summary_time='25:00'`` are all invalid; the integration's
``from_options_resilient`` falls each back to its default, so setup
SUCCEEDS, the day-rollover listener registers at the default hour 0
minute 0, and the startup backfill materializes the default horizon
window.
"""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.store import async_get_db_path
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass.async_add_executor_job)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
await pre.close()
entry = _wire(
make_entry(
options={
CONF_HORIZON_DAYS: True,
CONF_DAY_ROLLOVER_TIME: "99:99",
CONF_MORNING_SUMMARY_TIME: "25:00",
}
),
hass.registry,
)
assert (
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: "5"})
== DEFAULT_HORIZON_DAYS
)
assert (
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 0})
== DEFAULT_HORIZON_DAYS
assert await async_setup_entry(hass, entry) is True
# The day-rollover listener fell back to the default 00:00.
registration = hass.time_change.registrations[0]
assert registration["hour"] == 0
assert registration["minute"] == 0
# The startup backfill used the default horizon window.
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
records = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child_id, today.isoformat(), end)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
async def test_setup_keeps_horizon_when_sibling_option_is_bad(
hass, make_entry
) -> None:
"""A malformed sibling option does not reset the configured horizon.
``horizon_days=5`` is valid while ``morning_summary_time='25:00'`` is
invalid; ``from_options_resilient`` falls back ONLY the bad sibling,
so the entry's resolved settings keep ``horizon_days == 5``.
"""
entry = _wire(
make_entry(
options={
CONF_HORIZON_DAYS: 5,
CONF_MORNING_SUMMARY_TIME: "25:00",
}
),
hass.registry,
)
assert await async_setup_entry(hass, entry) is True
assert entry.runtime_data.settings.horizon_days == 5
async def test_regenerate_service_registered_and_materializes(
+6 -3
View File
@@ -51,7 +51,7 @@ NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path):
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
return database
@@ -1039,7 +1039,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight(
clock["reads"] += 1
return day0 if clock["reads"] == 1 else day1
import custom_components.nestquest.dao_instances as dao_instances_module
import custom_components.nestquest.core.dao_instances as dao_instances_module
monkeypatch.setattr(dao_instances_module, "_today", _fake_today)
@@ -1475,7 +1475,7 @@ async def _collect_records(database, child_ids, start, end) -> set:
def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None:
import custom_components.nestquest.materialize as materialize_module
import custom_components.nestquest.core.materialize as materialize_module
# Give each materialize run a DISTINCT batch stamp: production upserts
# rewrite generated_at on every re-run, so a shared pinned stamp would
@@ -1487,6 +1487,9 @@ def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None:
stamps["n"] += 1
return f"2026-06-01T00:00:{stamps['n']:02d}+00:00"
# The materialize() walk reads ``_now_stamp`` from its OWN module
# globals (now the HA-free core package), so the patch must target
# the core module, not the re-export shim the integration imports.
monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp)
async def _body(database):
+4 -4
View File
@@ -37,7 +37,7 @@ def _make_hass_mock():
def _open_db(path) -> NestQuestDatabase:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
_run(database.open(path))
return database
@@ -707,7 +707,7 @@ def test_noop_run_logs_no_migration(caplog, tmp_path) -> None:
try:
_migrate(database)
caplog.clear()
with caplog.at_level(logging.INFO, logger="custom_components.nestquest.migrations"):
with caplog.at_level(logging.INFO, logger="custom_components.nestquest.core.migrations"):
_migrate(database)
migrated = [
r for r in caplog.records if "migrated" in r.getMessage().lower()
@@ -722,7 +722,7 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None:
try:
_migrate(database)
caplog.clear()
with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.migrations"):
with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.core.migrations"):
_migrate(database)
assert any(
f"already at version {len(MIGRATIONS)}" in r.getMessage()
@@ -983,7 +983,7 @@ def test_concurrent_runners_serialize_and_apply_once(tmp_path) -> None:
results: dict[str, int] = {}
async def _main(tmp_name: str) -> None:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(tmp_name)
try:
# Start A, yield so it acquires the lock and enters its
+1 -1
View File
@@ -113,7 +113,7 @@ def _open_allowlist_db(tmp_path, name):
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
_run(database.open(tmp_path / name))
_run(apply_migrations(database))
return database
+10 -2
View File
@@ -364,11 +364,19 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None:
def test_module_uses_no_iso_week_functions() -> None:
"""D-004 guardrail: cycle math never reads ISO week numbers."""
"""D-004 guardrail: cycle math never reads ISO week numbers.
Scanned against ``custom_components.nestquest.core.presence`` (the
real presence model since task 510c1f78); the integration's
``presence.py`` is now a re-export shim, so scanning the shim is
vacuous. Mirrors ``test_recurrence_model.py``'s scan of
``core.recurrence``.
"""
from pathlib import Path
source = Path(
__import__("custom_components.nestquest.presence", fromlist=["__file__"]).__file__
__import__("custom_components.nestquest.core.presence",
fromlist=["__file__"]).__file__
).read_text()
assert "isocalendar" not in source
assert "isoweek" not in source
+2 -2
View File
@@ -50,7 +50,7 @@ NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
children = ChildrenDao(database)
@@ -1396,7 +1396,7 @@ def test_set_quest_definition_active_concurrent_opposite_transitions_return_own(
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "active-race.db")
try:
await apply_migrations(database)
+1 -1
View File
@@ -276,7 +276,7 @@ def test_recurrence_module_imports_only_stdlib() -> None:
from pathlib import Path
source = Path(
__import__("custom_components.nestquest.recurrence",
__import__("custom_components.nestquest.core.recurrence",
fromlist=["__file__"]).__file__
).read_text()
offenders = _scan_forbidden_imports(source)
+1 -1
View File
@@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
_month_end,
occurs_on,
occurrences_between,
)
from custom_components.nestquest.core.recurrence import _month_end
def _d(date: str) -> datetime.date:
+1 -1
View File
@@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
_nth_weekday_of_month,
occurs_on,
occurrences_between,
)
from custom_components.nestquest.core.recurrence import _nth_weekday_of_month
def _d(date: str) -> datetime.date:
+1 -1
View File
@@ -37,7 +37,7 @@ def _make_hass_mock():
def _open_db(path) -> NestQuestDatabase:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
_run(database.open(path))
return database
+8 -3
View File
@@ -36,7 +36,7 @@ def _make_hass_mock():
def _open_db(path) -> NestQuestDatabase:
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
_run(database.open(path))
return database
@@ -44,7 +44,7 @@ def _open_db(path) -> NestQuestDatabase:
async def _avalid_database(path) -> None:
"""Create a valid, migrated NestQuest database file (async, on the
running loop, for use inside pytest-asyncio tests)."""
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
try:
await apply_migrations(database)
@@ -442,6 +442,11 @@ def test_package_never_unlinks_or_truncates_db_files() -> None:
"""Guardrail: no code path may delete or overwrite an existing
database file. Scans the package for unlink/rmtree/write on the
DB filename constant or *.db patterns.
Recursive (``rglob``) so the bundled ``core/`` package (extracted
in task d0b691d8 / 510c1f78) is scanned alongside the integration's
own modules — a non-recursive ``glob`` skips ``core/`` and leaves
the guardrail vacuous against it.
"""
import re
from pathlib import Path
@@ -459,7 +464,7 @@ def test_package_never_unlinks_or_truncates_db_files() -> None:
re.IGNORECASE,
)
offenders: list[str] = []
for py in sorted(package.glob("*.py")):
for py in sorted(package.rglob("*.py")):
if pattern.search(py.read_text()):
offenders.append(py.name)
assert offenders == [], (
+30 -7
View File
@@ -23,7 +23,11 @@ def _make_hass(config_dir: Path, execute: bool = True):
return hass
def test_returns_absolute_path_from_config_path() -> None:
def test_returns_absolute_path_from_config_path(monkeypatch) -> None:
# Path resolution only: the parent-dir mkdir is a side effect of
# core.store and is exercised elsewhere; suppress it here so the
# absolute-path assertion can run against /config (non-writable).
monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None)
hass = _make_hass(Path("/config"), execute=False)
db_path = _run(store.async_get_db_path(hass))
hass.config.path.assert_called_once_with(SQLITE_DB_FILENAME)
@@ -75,18 +79,36 @@ def test_existing_directory_does_not_fail(tmp_path) -> None:
assert not db_path.exists()
def test_mkdir_runs_via_executor(tmp_path) -> None:
def test_mkdir_runs_off_event_loop(tmp_path, monkeypatch) -> None:
"""The parent-directory mkdir runs off the event loop via asyncio.to_thread.
core.store is Home-Assistant-free, so it schedules the blocking
``mkdir`` through :func:`asyncio.to_thread` rather than
``hass.async_add_executor_job``; the integration shim delegates to it
unchanged. The intent is preserved: the mkdir still runs off the
event loop, with ``_ensure_dir`` on the parent directory, and the
directory exists afterwards.
"""
nested = tmp_path / "deep" / "nested"
hass = _make_hass(nested)
to_thread_calls: list = []
real_to_thread = asyncio.to_thread
def _trace_to_thread(fn, *args, **kwargs):
to_thread_calls.append((fn, args, kwargs))
return real_to_thread(fn, *args, **kwargs)
monkeypatch.setattr(asyncio, "to_thread", _trace_to_thread)
db_path = _run(store.async_get_db_path(hass))
assert nested.is_dir()
hass.async_add_executor_job.assert_called_once()
args, kwargs = hass.async_add_executor_job.call_args
fn, fn_args = args[0], args[1:]
assert len(to_thread_calls) == 1, (
f"expected one asyncio.to_thread call, got {len(to_thread_calls)}"
)
fn, fn_args, fn_kwargs = to_thread_calls[0]
assert callable(fn)
assert fn is store._ensure_dir
assert fn_args == (nested,)
assert kwargs == {}
assert fn_kwargs == {}
assert db_path.parent == nested
assert db_path.parent.is_dir()
assert not db_path.exists()
@@ -180,7 +202,8 @@ def test_no_file_creation_calls_in_store() -> None:
assert "open(" not in source
def test_container_style_path_resolves() -> None:
def test_container_style_path_resolves(monkeypatch) -> None:
monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None)
hass = _make_hass(Path("/config"), execute=False)
assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME
+19 -34
View File
@@ -13,7 +13,9 @@ import datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
from conftest import freeze_nestquest_clock
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.core.settings import NestQuestSettings
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.db import NestQuestDatabase
@@ -38,7 +40,7 @@ def _make_hass_mock():
async def _prepare(path):
database = NestQuestDatabase(_make_hass_mock())
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
return database
@@ -53,35 +55,10 @@ def _freeze_clock(
) -> tuple[datetime.datetime, list]:
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
Only ``custom_components.nestquest``'s ``datetime`` binding is patched, so
the generation path's "today" reads this instant (localized to whatever
time zone it asks for) while every other module and the test harness keep
the real clock. Returns ``(frozen, requested)`` where ``frozen`` is the
instant normalized to UTC and ``requested`` is the list of ``tzinfo``
objects the code under test passed to ``now`` — so a test can prove the
generation path actually requested the configured HA time zone rather
than silently reading UTC.
Thin wrapper over :func:`conftest.freeze_nestquest_clock` kept for the
``(frozen, requested)`` return shape the timezone assertions read.
"""
import custom_components.nestquest as nestquest
frozen = instant.astimezone(datetime.timezone.utc)
requested: list = []
class _FrozenDatetime(datetime.datetime):
@classmethod
def now(cls, tz=None):
requested.append(tz)
if tz is None:
return frozen.replace(tzinfo=None)
return frozen.astimezone(tz)
fake_module = SimpleNamespace(
datetime=_FrozenDatetime,
timedelta=datetime.timedelta,
date=datetime.date,
)
monkeypatch.setattr(nestquest, "datetime", fake_module)
return frozen, requested
return freeze_nestquest_clock(monkeypatch, instant)
def _assert_requested_ha_timezone(requested, time_zone: str) -> None:
@@ -147,7 +124,9 @@ def test_horizon_uses_newyork_local_date_after_local_midnight(
import custom_components.nestquest as nestquest
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
@@ -187,7 +166,9 @@ def test_horizon_uses_local_date_not_utc_when_host_ahead(
import custom_components.nestquest as nestquest
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
@@ -231,7 +212,9 @@ def test_horizon_resolves_spring_forward_day_without_off_by_one(
requested_batches.append(requested)
local_today = instant.date()
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
@@ -280,7 +263,9 @@ def test_horizon_resolves_fall_back_day_without_off_by_one(
requested_batches.append(requested)
local_today = instant.date()
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
@@ -313,7 +298,7 @@ def test_definition_business_regeneration_uses_pinned_today_not_host(
business operations must regenerate against the pinned date and keep
that first local day, proving the threaded ``today`` wins.
"""
import custom_components.nestquest.dao_instances as dao_instances_module
import custom_components.nestquest.core.dao_instances as dao_instances_module
pinned = datetime.date(2026, 1, 14)
host_today = datetime.date(2026, 1, 15)