Feature 15: Domain Core Extraction #146
@@ -1,5 +1,28 @@
|
|||||||
# NestQuest Release Notes
|
# 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
|
## Version 0.5.0 — 2026-09-18
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|||||||
@@ -18,19 +18,16 @@ from homeassistant.helpers.event import async_track_time_change
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
|
||||||
CONF_HORIZON_DAYS,
|
|
||||||
CONF_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
|
||||||
DEFAULT_HORIZON_DAYS,
|
|
||||||
DEFAULT_UPDATE_INTERVAL,
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
)
|
)
|
||||||
|
from .core.settings import NestQuestSettings
|
||||||
from .frontend import async_register_frontend
|
from .frontend import async_register_frontend
|
||||||
from .coordinator import NestQuestCoordinator
|
from .coordinator import NestQuestCoordinator
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase, make_database
|
||||||
from .materialize import materialize as _materialize_run
|
from .materialize import materialize as _materialize_run
|
||||||
from .migrations import apply_migrations
|
from .migrations import apply_migrations
|
||||||
from .services import async_deregister_services, async_register_services
|
from .services import async_deregister_services, async_register_services
|
||||||
@@ -174,7 +171,7 @@ async def _async_open_database(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await _preflight_existing_file(hass, db_path)
|
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:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except sqlite3.DatabaseError as err:
|
except sqlite3.DatabaseError as err:
|
||||||
@@ -240,7 +237,7 @@ class NestQuestRuntimeData:
|
|||||||
"""Runtime data stored on a NestQuest config entry."""
|
"""Runtime data stored on a NestQuest config entry."""
|
||||||
|
|
||||||
entry_id: str
|
entry_id: str
|
||||||
options: dict[str, Any]
|
settings: NestQuestSettings
|
||||||
database: NestQuestDatabase
|
database: NestQuestDatabase
|
||||||
remove_update_listener: Callable[[], Any]
|
remove_update_listener: Callable[[], Any]
|
||||||
remove_time_change_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
|
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(
|
async def _run_horizon_materialization(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
horizon_days: int = DEFAULT_HORIZON_DAYS,
|
settings: NestQuestSettings,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
|
"""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
|
This is the ONE generation path: the startup backfill, the daily rollover
|
||||||
listener and the ``regenerate`` service all funnel through it, so there is
|
listener and the ``regenerate`` service all funnel through it, so there is
|
||||||
no duplicated materialization logic. ``horizon_days`` sizes the window
|
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
|
||||||
and defaults to :data:`~.const.DEFAULT_HORIZON_DAYS`; callers thread the
|
silent default; every caller already passes the entry's validated
|
||||||
entry's validated option value through.
|
: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)
|
time_zone = ZoneInfo(hass.config.time_zone)
|
||||||
today = datetime.datetime.now(time_zone).date()
|
today = datetime.datetime.now(time_zone).date()
|
||||||
start_date = today.isoformat()
|
start, end = settings.horizon_window(today)
|
||||||
end_date = (
|
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
|
||||||
today + datetime.timedelta(days=horizon_days)
|
|
||||||
).isoformat()
|
|
||||||
await _materialize_run(database, start_date, end_date, today=today)
|
|
||||||
|
|
||||||
|
|
||||||
def _register_day_rollover_listener(
|
def _register_day_rollover_listener(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
options: dict[str, Any],
|
settings: NestQuestSettings,
|
||||||
) -> Callable[[], Any]:
|
) -> Callable[[], Any]:
|
||||||
"""Register the daily rollover listener and return its remover.
|
"""Register the daily rollover listener and return its remover.
|
||||||
|
|
||||||
@@ -335,16 +303,15 @@ def _register_day_rollover_listener(
|
|||||||
first re-materializes the rolling horizon
|
first re-materializes the rolling horizon
|
||||||
``[today, today + horizon_days]`` through the shared
|
``[today, today + horizon_days]`` through the shared
|
||||||
:func:`_run_horizon_materialization` path (the entry's configured,
|
:func:`_run_horizon_materialization` path (the entry's configured,
|
||||||
validated ``horizon_days``), then runs the Feature 11 missed sweep
|
validated ``horizon_days`` carried on ``settings``), then runs the
|
||||||
— announcing yesterday's still-open quests as missed AFTER today's
|
Feature 11 missed sweep — announcing yesterday's still-open quests
|
||||||
instances exist, watermark-guarded so a re-fire of the listener the
|
as missed AFTER today's instances exist, watermark-guarded so a
|
||||||
same night announces nothing twice.
|
re-fire of the listener the same night announces nothing twice.
|
||||||
"""
|
"""
|
||||||
hour, minute = _day_rollover_hour_minute(options)
|
hour, minute = settings.day_rollover_hour_minute
|
||||||
horizon_days = _configured_horizon_days(options)
|
|
||||||
|
|
||||||
async def _run_materialization(_now: datetime.datetime) -> None:
|
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)
|
await run_missed_sweep(hass, database)
|
||||||
|
|
||||||
return async_track_time_change(
|
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
|
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
|
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
|
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():
|
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||||
database = getattr(runtime_data, "database", None)
|
database = getattr(runtime_data, "database", None)
|
||||||
@@ -379,9 +348,8 @@ def _make_regenerate_handler(hass: HomeAssistant):
|
|||||||
"live database; ignoring"
|
"live database; ignoring"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
horizon_days = _configured_horizon_days(runtime_data.options)
|
|
||||||
await _run_horizon_materialization(
|
await _run_horizon_materialization(
|
||||||
hass, runtime_data.database, horizon_days
|
hass, runtime_data.database, runtime_data.settings
|
||||||
)
|
)
|
||||||
|
|
||||||
return _regenerate
|
return _regenerate
|
||||||
@@ -495,18 +463,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
context_user_id=context_user_id,
|
context_user_id=context_user_id,
|
||||||
owner_ids=owner_ids,
|
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_update_listener = entry.add_update_listener(_async_update_listener)
|
||||||
remove_time_change_listener = _register_day_rollover_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
|
# Backfill any days the daily listener missed while the integration
|
||||||
# was off (or freshly installed): run the one idempotent walk over
|
# was off (or freshly installed): run the one idempotent walk over
|
||||||
# [today, today + horizon_days] once at setup, using the entry's
|
# [today, today + horizon_days] once at setup, using the entry's
|
||||||
# configured horizon. This runs through the SAME path as the daily
|
# configured horizon. This runs through the SAME path as the daily
|
||||||
# listener and regenerate service.
|
# listener and regenerate service.
|
||||||
await _run_horizon_materialization(
|
await _run_horizon_materialization(hass, database, settings)
|
||||||
hass, database, _configured_horizon_days(dict(entry.options))
|
|
||||||
)
|
|
||||||
# The missed sweep runs at startup too (Feature 11): if HA was
|
# The missed sweep runs at startup too (Feature 11): if HA was
|
||||||
# down at the rollover time, the previous night's still-open
|
# down at the rollover time, the previous night's still-open
|
||||||
# quests are announced as missed now instead of waiting for
|
# 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()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
runtime_data = NestQuestRuntimeData(
|
runtime_data = NestQuestRuntimeData(
|
||||||
entry_id=entry.entry_id,
|
entry_id=entry.entry_id,
|
||||||
options=dict(entry.options),
|
settings=settings,
|
||||||
database=database,
|
database=database,
|
||||||
remove_update_listener=remove_update_listener,
|
remove_update_listener=remove_update_listener,
|
||||||
remove_time_change_listener=remove_time_change_listener,
|
remove_time_change_listener=remove_time_change_listener,
|
||||||
|
|||||||
@@ -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
|
The admin allowlist business layer lives in
|
||||||
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
|
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
|
||||||
Assistant user IDs — never usernames or display names (feature
|
task 510c1f78); this module re-exports it so existing
|
||||||
guardrail) — and an empty allowlist must fail closed: every function
|
``from .admin_allowlist import ...`` references inside the integration
|
||||||
here refuses to write an empty allowlist, so the owner can never be
|
keep working.
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
from .core.admin_allowlist import * # noqa: F401,F403
|
||||||
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()]
|
|
||||||
|
|||||||
@@ -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
|
The children registry business layer lives in
|
||||||
:class:`~.dao_children.ChildrenDao`: callers get validation, name
|
:mod:`custom_components.nestquest.core.children` (extracted in
|
||||||
policies and the duplicate-name warning here, and never talk to the
|
task 510c1f78); this module re-exports it so existing
|
||||||
DAO or SQL directly. Children are never hard-deleted — deactivation
|
``from .children import ...`` references inside the integration keep
|
||||||
is a separate explicit operation (:func:`set_child_active`), so
|
working.
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
from .core.children import * # noqa: F401,F403
|
||||||
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()
|
|
||||||
|
|||||||
@@ -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
|
The completion business layer lives in
|
||||||
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
|
:mod:`custom_components.nestquest.core.completion` (extracted in
|
||||||
validation and the UTC occurred-at stamp here, and never talk to the
|
task 510c1f78); this module re-exports it so existing
|
||||||
DAO or SQL directly. This module is the only Feature-08 caller of
|
``from .completion import ...`` references inside the integration keep
|
||||||
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
working.
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from .core.completion import * # noqa: F401,F403
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from .core.const import * # noqa: F401,F403
|
||||||
|
|
||||||
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"),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 children/admin DAO lives in
|
||||||
the feature guardrail: callers get typed dataclasses back and never see
|
:mod:`custom_components.nestquest.core.dao_children` (extracted in
|
||||||
raw rows or SQL. Every method is async and runs through
|
task d0b691d8); this module re-exports it so existing
|
||||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
``from .dao_children import ...`` references inside the integration keep
|
||||||
executor and the event loop never blocks.
|
working.
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from .core.dao_children import * # noqa: F401,F403
|
||||||
from dataclasses import dataclass
|
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
The instances/completion-events DAO lives in
|
||||||
guardrails: callers get typed dataclasses back and never see raw rows
|
:mod:`custom_components.nestquest.core.dao_instances`
|
||||||
or SQL. Every method is async and runs through
|
(extracted in task d0b691d8); this module re-exports it so existing
|
||||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
``from .dao_instances import ...`` references inside the integration keep
|
||||||
executor and the event loop never blocks.
|
working.
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
from .core.dao_instances import * # noqa: F401,F403
|
||||||
from dataclasses import dataclass
|
from .core.dao_instances import _resolve_today # noqa: F401
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
The meta-state DAO lives in
|
||||||
integration itself rather than any domain table — currently the
|
:mod:`custom_components.nestquest.core.dao_meta` (extracted in task
|
||||||
Feature 11 missed-sweep watermark. Values are plain strings; callers
|
d0b691d8); this module re-exports it so existing
|
||||||
interpret them. All SQL for this table lives here per the feature
|
``from .dao_meta import ...`` references inside the integration keep
|
||||||
guardrails, and every method runs through
|
working.
|
||||||
:class:`~.db.NestQuestDatabase` so nothing blocks the event loop.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .dao_children import _connection_lock
|
from .core.dao_meta import * # noqa: F401,F403
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
The presence DAOs live in
|
||||||
guardrails: callers get typed dataclasses back and never see raw rows
|
:mod:`custom_components.nestquest.core.dao_presence` (extracted in
|
||||||
or SQL. Every method is async and runs through
|
task d0b691d8); this module re-exports them so existing
|
||||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
``from .dao_presence import ...`` references inside the integration keep
|
||||||
executor and the event loop never blocks.
|
working.
|
||||||
|
|
||||||
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from .core.dao_presence import * # noqa: F401,F403
|
||||||
|
|
||||||
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
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from typing import Any, TypeVar
|
||||||
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 homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .core.db import ExecutionResult, NestQuestDatabase
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
|
||||||
class ExecutionResult:
|
|
||||||
"""Plain-data outcome of one statement.
|
|
||||||
|
|
||||||
``rowcount`` and ``lastrowid`` mirror the corresponding
|
T = TypeVar("T")
|
||||||
:class:`sqlite3.Cursor` attributes; both are captured on the executor
|
|
||||||
thread so no sqlite3 object ever crosses back to the event loop.
|
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
|
return NestQuestDatabase(hass.async_add_executor_job)
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
The transition-event payload construction lives in
|
||||||
in :mod:`~.const`): ``nestquest_quest_completed`` and
|
:mod:`custom_components.nestquest.core.events` (extracted in
|
||||||
``nestquest_quest_uncompleted`` fire from the completion service
|
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
|
||||||
handlers on an actual transition (a no-op re-complete or re-uncomplete
|
shim keeps the integration's old ``fire_quest_completed`` /
|
||||||
fires nothing), and ``nestquest_child_day_complete`` fires when a
|
``fire_quest_uncompleted`` API (the complete/uncomplete service
|
||||||
completion clears the child's whole day (quests were owed and none
|
handlers call them with ``hass``), reads the HA-local timezone from
|
||||||
remain — a zero-quest day can never fire it, matching the all-done
|
``hass.config.time_zone`` to derive ``today``, asks the core builder
|
||||||
binary sensor's rule). ``nestquest_quest_missed`` is the documented
|
for the events, and fires each on ``hass.bus`` with the documented
|
||||||
contract Feature 11's nightly sweep fires; nothing here fires it.
|
payloads. The four blueprints keep working with identical payloads.
|
||||||
|
|
||||||
Payload policy: one dict carrying ``child_id``, ``child_name``,
|
The completed event is fired BEFORE the day-complete rule evaluates:
|
||||||
``instance_id``, ``quest_title``, the instance's ``window``/
|
``fire_quest_completed`` asks
|
||||||
``due_date``/``due_time`` so automation authors can filter without a
|
:func:`build_quest_completed_event` for the completed event and fires
|
||||||
database lookup, and ``occurred_at`` — the event's own strict UTC
|
it immediately, THEN reads the HA-local ``today``, asks
|
||||||
ISO-8601 timestamp. Completion payloads additionally carry
|
:func:`build_child_day_complete_event` for the day-complete event, and
|
||||||
``was_on_time``. No other personal data: names and titles are the
|
fires it (if any) afterwards. This restores the pre-extraction
|
||||||
household's own records, and nothing beyond them leaks.
|
ordering — a failure in the day-complete DB reads or the ZoneInfo
|
||||||
|
lookup cannot suppress the completed event.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from .completion import derive_state
|
from .core.db import NestQuestDatabase
|
||||||
from .const import (
|
from .core.events import (
|
||||||
EVENT_CHILD_DAY_COMPLETE,
|
build_child_day_complete_event,
|
||||||
EVENT_QUEST_COMPLETED,
|
build_quest_completed_event,
|
||||||
EVENT_QUEST_UNCOMPLETED,
|
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,
|
__all__ = ["fire_quest_completed", "fire_quest_uncompleted"]
|
||||||
#: 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 fire_quest_completed(
|
async def fire_quest_completed(
|
||||||
@@ -80,51 +44,36 @@ async def fire_quest_completed(
|
|||||||
"""Fire ``nestquest_quest_completed`` and, when the completion
|
"""Fire ``nestquest_quest_completed`` and, when the completion
|
||||||
cleared the child's whole day, ``nestquest_child_day_complete``.
|
cleared the child's whole day, ``nestquest_child_day_complete``.
|
||||||
|
|
||||||
Called by the complete_quest service handler AFTER an actual
|
Thin HA wiring over the HA-free
|
||||||
completion transition, with the completion's ``was_on_time`` —
|
:func:`custom_components.nestquest.core.events.build_quest_completed_event`
|
||||||
computed under the completion lock and carried on the result — so
|
and
|
||||||
the payload never re-reads mutable latest state a racing reversal
|
:func:`build_child_day_complete_event`: the completed event is
|
||||||
could have replaced. A no-op re-complete never reaches here.
|
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()
|
completed = await build_quest_completed_event(
|
||||||
payload = await _instance_payload(database, instance_id, now_stamp)
|
database,
|
||||||
payload["was_on_time"] = was_on_time
|
instance_id,
|
||||||
hass.bus.async_fire(EVENT_QUEST_COMPLETED, payload)
|
was_on_time=was_on_time,
|
||||||
|
|
||||||
# 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()
|
|
||||||
)
|
)
|
||||||
events = CompletionEventsDao(database)
|
hass.bus.async_fire(*completed)
|
||||||
remaining = 0
|
|
||||||
for instance in instances:
|
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||||
latest_for_instance = await events.get_latest_for_instance(
|
day_complete = await build_child_day_complete_event(
|
||||||
instance.id
|
database, completed[1], today=today
|
||||||
)
|
)
|
||||||
if derive_state(instance, latest_for_instance, today) != "done":
|
if day_complete is not None:
|
||||||
remaining += 1
|
hass.bus.async_fire(*day_complete)
|
||||||
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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def fire_quest_uncompleted(
|
async def fire_quest_uncompleted(
|
||||||
hass, database: NestQuestDatabase, instance_id: int
|
hass, database: NestQuestDatabase, instance_id: int
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
|
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
|
||||||
payload = await _instance_payload(database, instance_id, _now_stamp())
|
events = await build_quest_uncompleted_events(database, instance_id)
|
||||||
hass.bus.async_fire(EVENT_QUEST_UNCOMPLETED, payload)
|
for event_type, payload in events:
|
||||||
|
hass.bus.async_fire(event_type, payload)
|
||||||
|
|||||||
@@ -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
|
The materialization walk lives in
|
||||||
active quest definitions into concrete ``quest_instances`` rows over a
|
:mod:`custom_components.nestquest.core.materialize` (extracted in
|
||||||
date range. It is the pure walk over definitions x dates x assignees x
|
task 510c1f78); this module re-exports it so existing
|
||||||
windows; HA scheduling (day-rollover triggers) is a separate later
|
``from .materialize import ...`` references inside the integration keep
|
||||||
task and lives elsewhere.
|
working.
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
from .core.materialize import * # noqa: F401,F403
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
The versioned migration runner lives in
|
||||||
a single row with the schema version the database file currently
|
:mod:`custom_components.nestquest.core.migrations`
|
||||||
reflects. Migrations are declared as an ordered list: entry ``n``
|
(extracted in task d0b691d8); this module re-exports it so existing
|
||||||
upgrades version ``n-1`` to version ``n``. Applying a migration means
|
``from .migrations import ...`` references inside the integration keep
|
||||||
running its DDL inside one transaction and stamping the new version in
|
working.
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from .core.migrations import * # noqa: F401,F403
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Options flow for NestQuest post-setup settings."""
|
"""Options flow for NestQuest post-setup settings."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -33,18 +32,17 @@ from .const import (
|
|||||||
DEFAULT_UPDATE_INTERVAL,
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
MIN_UPDATE_INTERVAL,
|
MIN_UPDATE_INTERVAL,
|
||||||
|
TIME_PATTERN,
|
||||||
)
|
)
|
||||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||||
|
|
||||||
MIN_HORIZON_DAYS = 1
|
MIN_HORIZON_DAYS = 1
|
||||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
MIN_PANEL_IDLE_TIMEOUT = 30
|
||||||
|
|
||||||
_TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_time(value: str) -> bool:
|
def _parse_time(value: str) -> bool:
|
||||||
"""Return True when value is a valid 24-hour HH:MM string."""
|
"""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]:
|
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||||
|
|||||||
@@ -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
|
The presence and custody model lives in
|
||||||
timezone logic — it operates on plain calendar dates only (feature
|
:mod:`custom_components.nestquest.core.presence` (extracted in
|
||||||
guardrail). The later engine (cycle position, ``is_present``,
|
task 510c1f78); this module re-exports it so existing
|
||||||
overrides, previews) builds directly on this model.
|
``from .presence import ...`` references inside the integration keep
|
||||||
|
working.
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
from .core.presence import * # noqa: F401,F403
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
The quest definitions business layer lives in
|
||||||
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
|
:mod:`custom_components.nestquest.core.quest_definitions` (extracted in
|
||||||
all-or-nothing create/edit here, and never touch the DAO SQL directly.
|
task 510c1f78); this module re-exports it so existing
|
||||||
:func:`create_quest_definition` is the create path,
|
``from .quest_definitions import ...`` references inside the integration
|
||||||
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
|
keep working.
|
||||||
: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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
from .core.quest_definitions import * # noqa: F401,F403
|
||||||
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)]
|
|
||||||
|
|||||||
@@ -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
|
The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence`
|
||||||
frozen dataclass with no Home Assistant and no database imports. The
|
(extracted in task d0b691d8); this module re-exports it so existing
|
||||||
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
|
``from .recurrence import ...`` references inside the integration keep
|
||||||
serializes its dict form into the ``schedule_rules`` table.
|
working.
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
from .core.recurrence import * # noqa: F401,F403
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
The declarative schema DDL lives in
|
||||||
tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without
|
:mod:`custom_components.nestquest.core.schema` (extracted in
|
||||||
touching this module's tables. All statements use
|
task d0b691d8); this module re-exports it so existing
|
||||||
``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned
|
``from .schema import ...`` references inside the integration keep
|
||||||
migration runner is a separate later concern.
|
working.
|
||||||
|
|
||||||
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 __future__ import annotations
|
||||||
|
|
||||||
from .db import NestQuestDatabase
|
from .core.schema import * # noqa: F401,F403
|
||||||
|
|
||||||
#: 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)
|
|
||||||
|
|||||||
@@ -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
|
The service policy registry lives in
|
||||||
either ``open`` or ``admin_only``. The permission decorator (later
|
:mod:`custom_components.nestquest.core.service_policy` (extracted in
|
||||||
Feature 09 tasks) reads this table; it does not take per-handler
|
task 510c1f78); this module re-exports it so existing
|
||||||
policy arguments.
|
``from .service_policy import ...`` references inside the integration
|
||||||
|
keep working.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .const import (
|
from .core.service_policy import * # noqa: F401,F403
|
||||||
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)}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ from . import children as children_layer
|
|||||||
from . import completion as completion_layer
|
from . import completion as completion_layer
|
||||||
from . import quest_definitions as quest_definitions_layer
|
from . import quest_definitions as quest_definitions_layer
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_HORIZON_DAYS,
|
|
||||||
DEFAULT_HORIZON_DAYS,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
DOMAIN_SERVICES,
|
DOMAIN_SERVICES,
|
||||||
QUEST_WINDOWS,
|
QUEST_WINDOWS,
|
||||||
@@ -258,12 +256,7 @@ async def _refresh_entities(runtime: Any) -> None:
|
|||||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
||||||
time_zone = ZoneInfo(hass.config.time_zone)
|
time_zone = ZoneInfo(hass.config.time_zone)
|
||||||
today = datetime.datetime.now(time_zone).date()
|
today = datetime.datetime.now(time_zone).date()
|
||||||
value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
return {"today": today, "horizon_days": runtime.settings.horizon_days}
|
||||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
||||||
horizon_days = DEFAULT_HORIZON_DAYS
|
|
||||||
else:
|
|
||||||
horizon_days = value
|
|
||||||
return {"today": today, "horizon_days": horizon_days}
|
|
||||||
|
|
||||||
|
|
||||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
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
|
||||||
|
|
||||||
|
__all__ = ["async_get_db_path", "_ensure_dir"]
|
||||||
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(hass: HomeAssistant) -> Path:
|
async def async_get_db_path(hass: HomeAssistant) -> Path:
|
||||||
"""Return the absolute path to the NestQuest SQLite database file."""
|
"""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)
|
Resolves the database file from ``hass.config.path`` and delegates to
|
||||||
return db_path
|
: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))
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pytest -q``); it is never meant to run against a real HA install.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import datetime
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import inspect
|
import inspect
|
||||||
import sys
|
import sys
|
||||||
@@ -829,6 +830,24 @@ def make_hass() -> tuple:
|
|||||||
return hass, registry
|
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
|
@pytest.fixture
|
||||||
async def hass():
|
async def hass():
|
||||||
"""Provide a standard-shaped hass fixture bound to the running test loop.
|
"""Provide a standard-shaped hass fixture bound to the running test loop.
|
||||||
@@ -899,3 +918,39 @@ def make_config_flow_entry():
|
|||||||
return SimpleNamespace(domain=domain, entry_id=entry_id)
|
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
|
||||||
@@ -31,7 +31,7 @@ def _make_hass_mock():
|
|||||||
def _with_db(tmp_path, name):
|
def _with_db(tmp_path, name):
|
||||||
def _run_test(body):
|
def _run_test(body):
|
||||||
async def _main():
|
async def _main():
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(tmp_path / name)
|
await database.open(tmp_path / name)
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ def _make_hass_mock():
|
|||||||
def _with_db(tmp_path, name):
|
def _with_db(tmp_path, name):
|
||||||
def _run_test(body):
|
def _run_test(body):
|
||||||
async def _main():
|
async def _main():
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(tmp_path / name)
|
await database.open(tmp_path / name)
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
@@ -141,7 +141,7 @@ def test_create_child_duplicate_name_allowed_and_warned(
|
|||||||
async def _body(database):
|
async def _body(database):
|
||||||
first = await create_child(database, "Ada")
|
first = await create_child(database, "Ada")
|
||||||
with caplog.at_level(
|
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 ")
|
second = await create_child(database, " ADA ")
|
||||||
# Whitespace/case-normalised duplicate: allowed, both rows exist.
|
# 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()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "create-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
logger = logging.getLogger(
|
logger = logging.getLogger(
|
||||||
"custom_components.nestquest.children"
|
"custom_components.nestquest.core.children"
|
||||||
)
|
)
|
||||||
seen: list[logging.LogRecord] = []
|
seen: list[logging.LogRecord] = []
|
||||||
|
|
||||||
@@ -633,7 +633,7 @@ def test_edit_child_concurrent_opposite_edits_return_own_values(
|
|||||||
gate_open = asyncio.Event()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "edit-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
@@ -685,7 +685,7 @@ def test_set_child_active_concurrent_opposite_transitions_return_own(
|
|||||||
gate_open = asyncio.Event()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "active-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ D1 = _future_day(1)
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path) -> tuple:
|
async def _prepare(path) -> tuple:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
children = ChildrenDao(database)
|
children = ChildrenDao(database)
|
||||||
@@ -630,7 +630,7 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
|||||||
gate_open = asyncio.Event()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "complete-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
@@ -1060,7 +1060,7 @@ def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
|||||||
gate_open = asyncio.Event()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "uncomplete-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ def test_flow_strings_match_translations(
|
|||||||
def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
|
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."""
|
"""Ensure no module other than const.py hardcodes the domain or db filename."""
|
||||||
pkg_dir = Path(config_flow.__file__).parent
|
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"
|
assert len(py_files) > 0, "No python modules found in package to check"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -106,7 +106,7 @@ def test_window_clock_ranges_cover_every_window() -> None:
|
|||||||
def test_no_hardcoded_domain_or_db_filename_in_modules() -> 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."""
|
"""Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME."""
|
||||||
pkg_dir = Path(const.__file__).parent
|
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"
|
assert len(py_files) > 0, "No python modules found in package to check"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -30,7 +30,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
|
|
||||||
def _open_db(path) -> NestQuestDatabase:
|
def _open_db(path) -> NestQuestDatabase:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
_run(database.open(path))
|
_run(database.open(path))
|
||||||
_run(apply_migrations(database))
|
_run(apply_migrations(database))
|
||||||
return database
|
return database
|
||||||
@@ -319,7 +319,7 @@ def test_reorder_concurrent_calls_serialize(tmp_path) -> None:
|
|||||||
RuntimeError.
|
RuntimeError.
|
||||||
"""
|
"""
|
||||||
async def _main() -> None:
|
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")
|
await database.open(tmp_path / "reorder-concurrent.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
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.
|
'see no row' and one of them raise IntegrityError.
|
||||||
"""
|
"""
|
||||||
async def _main() -> None:
|
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")
|
await database.open(tmp_path / "admin-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
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"]
|
scan_roots = [package, repo_root / "tests"]
|
||||||
|
|
||||||
allowed = {
|
allowed = {
|
||||||
"custom_components/nestquest/dao_children.py", # the DAO itself
|
"custom_components/nestquest/core/dao_children.py", # the DAO itself
|
||||||
"custom_components/nestquest/dao_rules.py", # validates child
|
"custom_components/nestquest/core/dao_rules.py", # validates child
|
||||||
# activeness on assignment (FK to children, done-condition
|
# activeness on assignment (FK to children, done-condition
|
||||||
# 'validates that the assigned child is active')
|
# '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
|
# schedule/override child exists before writing (same FK shape
|
||||||
# as dao_rules: one existence SELECT per write)
|
# 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)
|
# instance/event child exists before writing (same FK shape)
|
||||||
"custom_components/nestquest/schema.py", # declares the DDL
|
"custom_components/nestquest/core/schema.py", # declares the DDL
|
||||||
"custom_components/nestquest/migrations.py", # applies the DDL
|
"custom_components/nestquest/core/migrations.py", # applies the DDL
|
||||||
"tests/test_schema.py", # tests the DDL
|
"tests/test_schema.py", # tests the DDL
|
||||||
"tests/test_migrations.py", # tests migration application
|
"tests/test_migrations.py", # tests migration application
|
||||||
"tests/test_dao_children.py", # this file, scanned separately
|
"tests/test_dao_children.py", # this file, scanned separately
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ D20 = _future_day(20)
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path) -> tuple:
|
async def _prepare(path) -> tuple:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
children = ChildrenDao(database)
|
children = ChildrenDao(database)
|
||||||
@@ -352,7 +352,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
|
|||||||
|
|
||||||
monkeypatch = pytest.MonkeyPatch()
|
monkeypatch = pytest.MonkeyPatch()
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"custom_components.nestquest.dao_instances.datetime",
|
"custom_components.nestquest.core.dao_instances.datetime",
|
||||||
_ShiftingModule(),
|
_ShiftingModule(),
|
||||||
)
|
)
|
||||||
try:
|
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
|
# the lock, the IN-LOCK re-check sees tomorrow and must
|
||||||
# reject the insert — the stale call-date pre-check alone
|
# reject the insert — the stale call-date pre-check alone
|
||||||
# would have let it through.
|
# 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():
|
async def _lock_holder():
|
||||||
# Hold the CONNECTION lock across a transaction until
|
# 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
|
return None
|
||||||
|
|
||||||
async def _main():
|
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")
|
await database.open(tmp_path / "upsert-rollover.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
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
|
# INSERT statements are stripped before scanning so only genuine
|
||||||
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
|
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
|
||||||
insert_exempt = {
|
insert_exempt = {
|
||||||
"custom_components/nestquest/dao_instances.py",
|
"custom_components/nestquest/core/dao_instances.py",
|
||||||
"custom_components/nestquest/migrations.py", # the rebuild
|
"custom_components/nestquest/core/migrations.py", # the rebuild
|
||||||
# migrations re-create the table via INSERT..SELECT (append-
|
# migrations re-create the table via INSERT..SELECT (append-
|
||||||
# order preserved); UPDATE/DELETE stays forbidden everywhere
|
# order preserved); UPDATE/DELETE stays forbidden everywhere
|
||||||
"tests/test_schema.py",
|
"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:
|
def test_append_concurrent_events_serialize(tmp_path) -> None:
|
||||||
"""Concurrent appends for the same instance all land, in order."""
|
"""Concurrent appends for the same instance all land, in order."""
|
||||||
async def _main():
|
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")
|
await database.open(tmp_path / "append-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class World:
|
|||||||
|
|
||||||
|
|
||||||
async def _world(path) -> 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 database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
world = World(database)
|
world = World(database)
|
||||||
@@ -441,7 +441,7 @@ def test_completion_events_absolutely_no_mutation_path() -> None:
|
|||||||
)
|
)
|
||||||
offenders = [
|
offenders = [
|
||||||
py.name
|
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())
|
if py.name != "__pycache__" and mutation.search(py.read_text())
|
||||||
]
|
]
|
||||||
assert offenders == []
|
assert offenders == []
|
||||||
@@ -33,7 +33,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path) -> tuple:
|
async def _prepare(path) -> tuple:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
schedules = PresenceSchedulesDao(database)
|
schedules = PresenceSchedulesDao(database)
|
||||||
@@ -196,7 +196,7 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
|
|||||||
return fn(*args)
|
return fn(*args)
|
||||||
|
|
||||||
hass.async_add_executor_job = _gated_executor
|
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")
|
await database.open(tmp_path / "upsert-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
@@ -553,9 +553,9 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
|
|||||||
scan_roots = [package, repo_root / "tests"]
|
scan_roots = [package, repo_root / "tests"]
|
||||||
|
|
||||||
allowed = {
|
allowed = {
|
||||||
"custom_components/nestquest/dao_presence.py", # this DAO
|
"custom_components/nestquest/core/dao_presence.py", # this DAO
|
||||||
"custom_components/nestquest/schema.py", # declares the DDL
|
"custom_components/nestquest/core/schema.py", # declares the DDL
|
||||||
"custom_components/nestquest/migrations.py", # applies the DDL
|
"custom_components/nestquest/core/migrations.py", # applies the DDL
|
||||||
"tests/test_schema.py", # tests the DDL
|
"tests/test_schema.py", # tests the DDL
|
||||||
"tests/test_migrations.py", # tests migration application
|
"tests/test_migrations.py", # tests migration application
|
||||||
"tests/test_dao_presence.py", # this file, scanned separately
|
"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)
|
return fn(*args)
|
||||||
|
|
||||||
hass.async_add_executor_job = _gated_executor
|
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")
|
await database.open(tmp_path / "override-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path) -> tuple:
|
async def _prepare(path) -> tuple:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
rules = ScheduleRulesDao(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.
|
create's validation pauses mid-flight before the delete starts.
|
||||||
"""
|
"""
|
||||||
async def _main(order: str):
|
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")
|
await database.open(tmp_path / f"rule-delete-race-{order}.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
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"]
|
scan_roots = [package, repo_root / "tests"]
|
||||||
|
|
||||||
allowed = {
|
allowed = {
|
||||||
"custom_components/nestquest/dao_rules.py", # this DAO
|
"custom_components/nestquest/core/dao_rules.py", # this DAO
|
||||||
"custom_components/nestquest/dao_instances.py", # validates the
|
"custom_components/nestquest/core/dao_instances.py", # validates the
|
||||||
# definition exists + fetches its assignee before generating an
|
# definition exists + fetches its assignee before generating an
|
||||||
# instance (the instance must snapshot the definition's child)
|
# instance (the instance must snapshot the definition's child)
|
||||||
"custom_components/nestquest/schema.py", # declares the DDL
|
"custom_components/nestquest/core/schema.py", # declares the DDL
|
||||||
"custom_components/nestquest/migrations.py", # applies the DDL
|
"custom_components/nestquest/core/migrations.py", # applies the DDL
|
||||||
"tests/test_schema.py", # tests the DDL
|
"tests/test_schema.py", # tests the DDL
|
||||||
"tests/test_migrations.py", # tests migration application
|
"tests/test_migrations.py", # tests migration application
|
||||||
"tests/test_dao_rules.py", # this file, scanned separately
|
"tests/test_dao_rules.py", # this file, scanned separately
|
||||||
|
|||||||
+28
-26
@@ -11,9 +11,9 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
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
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
|
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
|
||||||
hass = _make_hass_mock()
|
hass = _make_hass_mock()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
_run(database.open(path))
|
_run(database.open(path))
|
||||||
return database, hass
|
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:
|
def test_open_delegates_connection_to_executor(tmp_path) -> None:
|
||||||
hass = _make_hass_mock()
|
hass = _make_hass_mock()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
_run(database.open(tmp_path / "exec.db"))
|
_run(database.open(tmp_path / "exec.db"))
|
||||||
first_call = hass.async_add_executor_job.call_args_list[0]
|
first_call = hass.async_add_executor_job.call_args_list[0]
|
||||||
assert callable(first_call.args[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:
|
def test_open_twice_returns_same_wrapper_without_second_connect(tmp_path) -> None:
|
||||||
path = tmp_path / "once.db"
|
path = tmp_path / "once.db"
|
||||||
hass = _make_hass_mock()
|
hass = _make_hass_mock()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
first = _run(database.open(path))
|
first = _run(database.open(path))
|
||||||
second = _run(database.open(path))
|
second = _run(database.open(path))
|
||||||
assert first is database
|
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:
|
async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
|
||||||
"""The executor surface is awaited per wrapper call on a live loop."""
|
"""The executor surface is awaited per wrapper call on a live loop."""
|
||||||
hass = _make_hass_mock()
|
hass = _make_hass_mock()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "loop.db")
|
await database.open(tmp_path / "loop.db")
|
||||||
|
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
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:
|
async def test_public_methods_return_only_plain_data(tmp_path) -> None:
|
||||||
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
|
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
|
||||||
hass = _make_hass_mock()
|
hass = _make_hass_mock()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "plain.db")
|
await database.open(tmp_path / "plain.db")
|
||||||
result = await database.execute(
|
result = await database.execute(
|
||||||
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
|
"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:
|
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."""
|
"""With the conftest hass (real run_in_executor), queries still succeed."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "real.db")
|
await database.open(tmp_path / "real.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||||
assert await database.fetch_one("SELECT 1") == (1,)
|
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)
|
monkeypatch.setattr(db_module.sqlite3, "connect", _traced_connect)
|
||||||
|
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "threads.db")
|
await database.open(tmp_path / "threads.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||||
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
|
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.
|
"""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
|
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")
|
source = Path(db_module.__file__).read_text(encoding="utf-8")
|
||||||
tree = ast.parse(source, filename=str(db_module.__file__))
|
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(
|
and any(
|
||||||
isinstance(sub, ast.Call)
|
isinstance(sub, ast.Call)
|
||||||
and isinstance(sub.func, ast.Attribute)
|
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)
|
for sub in ast.walk(node)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -375,7 +377,7 @@ async def test_commit_failure_rolls_back_and_next_transaction_works(
|
|||||||
must run cleanly.
|
must run cleanly.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "deferred.db")
|
await database.open(tmp_path / "deferred.db")
|
||||||
await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
|
await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
|
||||||
await database.execute(
|
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.
|
propagates, and a follow-up open() must succeed cleanly.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(executor_for(hass))
|
||||||
|
|
||||||
open_started = asyncio.Event()
|
open_started = asyncio.Event()
|
||||||
release_open = 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.
|
clear its state, and stay usable for the next transaction.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(executor_for(hass))
|
||||||
await database.open(tmp_path / "cancel-begin.db")
|
await database.open(tmp_path / "cancel-begin.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
|
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.
|
transaction, and the wrapper must stay usable.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(executor_for(hass))
|
||||||
await database.open(tmp_path / "cancel-commit.db")
|
await database.open(tmp_path / "cancel-commit.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
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.
|
not persist.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(executor_for(hass))
|
||||||
await database.open(tmp_path / "cancel-execute.db")
|
await database.open(tmp_path / "cancel-execute.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
|
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.
|
commit failure behind the rollback failure.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(executor_for(hass))
|
||||||
await database.open(tmp_path / "both-fail.db")
|
await database.open(tmp_path / "both-fail.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
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:
|
async def test_transaction_persists_across_connections(tmp_path) -> None:
|
||||||
"""A committed transaction is visible to a brand-new connection."""
|
"""A committed transaction is visible to a brand-new connection."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "persist.db")
|
await database.open(tmp_path / "persist.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
async with database.transaction():
|
async with database.transaction():
|
||||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
|
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
|
||||||
await database.close()
|
await database.close()
|
||||||
|
|
||||||
other = NestQuestDatabase(hass)
|
other = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await other.open(tmp_path / "persist.db")
|
await other.open(tmp_path / "persist.db")
|
||||||
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
|
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
|
||||||
await other.close()
|
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.
|
while a transaction is open queue behind it on the wrapper lock.
|
||||||
"""
|
"""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "serial.db")
|
await database.open(tmp_path / "serial.db")
|
||||||
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
|
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
|
||||||
tx_rounds, writers, writer_rounds = 6, 5, 8
|
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:
|
async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> None:
|
||||||
"""A second transaction caller is rejected with a clear error."""
|
"""A second transaction caller is rejected with a clear error."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "reject.db")
|
await database.open(tmp_path / "reject.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
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:
|
async def test_transaction_blocks_other_statements_until_commit(tmp_path) -> None:
|
||||||
"""A statement issued during an open transaction queues behind it."""
|
"""A statement issued during an open transaction queues behind it."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "block.db")
|
await database.open(tmp_path / "block.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
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:
|
) -> None:
|
||||||
"""A second concurrent transaction() gets a clear RuntimeError."""
|
"""A second concurrent transaction() gets a clear RuntimeError."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "concurrent-tx.db")
|
await database.open(tmp_path / "concurrent-tx.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
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:
|
async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
|
||||||
"""close() serializes: an open transaction finishes before shutdown."""
|
"""close() serializes: an open transaction finishes before shutdown."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "closerace.db")
|
await database.open(tmp_path / "closerace.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
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)
|
await asyncio.gather(txn_task, close_task)
|
||||||
assert database.connected is False
|
assert database.connected is False
|
||||||
|
|
||||||
witness = NestQuestDatabase(hass)
|
witness = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await witness.open(tmp_path / "closerace.db")
|
await witness.open(tmp_path / "closerace.db")
|
||||||
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
|
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
|
||||||
await witness.close()
|
await witness.close()
|
||||||
@@ -886,7 +888,7 @@ async def test_close_during_transaction_from_same_task_is_rejected(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""close() inside its own transaction body raises instead of deadlocking."""
|
"""close() inside its own transaction body raises instead of deadlocking."""
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
database = NestQuestDatabase(hass)
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await database.open(tmp_path / "selfclose.db")
|
await database.open(tmp_path / "selfclose.db")
|
||||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
await database.execute("CREATE TABLE t (id INTEGER)")
|
||||||
with pytest.raises(RuntimeError, match="transaction"):
|
with pytest.raises(RuntimeError, match="transaction"):
|
||||||
|
|||||||
+423
-9
@@ -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$")
|
_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(
|
entry = wire_entry_to_registry(
|
||||||
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.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"},
|
{"action": "create", "display_name": "Ada"},
|
||||||
context=ADMIN_CTX,
|
context=ADMIN_CTX,
|
||||||
)
|
)
|
||||||
child = None
|
|
||||||
from custom_components.nestquest.children import list_children
|
from custom_components.nestquest.children import list_children
|
||||||
|
|
||||||
child = (await list_children(database))[0]
|
child = (await list_children(database))[0]
|
||||||
@@ -51,18 +62,16 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",)):
|
|||||||
database,
|
database,
|
||||||
"Brush teeth",
|
"Brush teeth",
|
||||||
ScheduleRule.from_dict(
|
ScheduleRule.from_dict(
|
||||||
{"rule_type": "daily", "start_date": __import__("datetime").date.today().isoformat()}
|
{"rule_type": "daily", "start_date": seed_date.isoformat()}
|
||||||
),
|
),
|
||||||
[child.id],
|
[child.id],
|
||||||
list(windows),
|
list(windows),
|
||||||
)
|
)
|
||||||
import datetime
|
today_iso = seed_date.isoformat()
|
||||||
|
end_iso = (seed_date + datetime.timedelta(days=3)).isoformat()
|
||||||
today = datetime.date.today().isoformat()
|
await materialize(database, today_iso, end_iso, today=seed_date)
|
||||||
end = (datetime.date.today() + datetime.timedelta(days=3)).isoformat()
|
|
||||||
await materialize(database, today, end, today=datetime.date.today())
|
|
||||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||||
child.id, today, today
|
child.id, today_iso, today_iso
|
||||||
)
|
)
|
||||||
return entry, child, instances
|
return entry, child, instances
|
||||||
|
|
||||||
@@ -377,3 +386,408 @@ async def test_forced_refreshes_serialize_no_stale_publish(
|
|||||||
assert order == ["start", "end", "start", "end"], (
|
assert order == ["start", "end", "start", "end"], (
|
||||||
"overlapping refreshes must serialize: " + str(order)
|
"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
@@ -12,6 +12,7 @@ from custom_components.nestquest.const import (
|
|||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
|
CONF_MORNING_SUMMARY_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
SERVICE_REGENERATE,
|
SERVICE_REGENERATE,
|
||||||
DOMAIN as DOMAIN_CONST,
|
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
|
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)
|
entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry)
|
||||||
await async_setup_entry(hass, entry)
|
await async_setup_entry(hass, entry)
|
||||||
runtime = entry.runtime_data
|
runtime = entry.runtime_data
|
||||||
assert runtime.entry_id == entry.entry_id
|
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)
|
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
|
# Seed the database BEFORE setup: a restart reopens an existing file that
|
||||||
# already holds definitions. The startup backfill must pick them up.
|
# already holds definitions. The startup backfill must pick them up.
|
||||||
db_path = await async_get_db_path(hass)
|
db_path = await async_get_db_path(hass)
|
||||||
pre = NestQuestDatabase(hass)
|
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await pre.open(db_path)
|
await pre.open(db_path)
|
||||||
await apply_migrations(pre)
|
await apply_migrations(pre)
|
||||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
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
|
from custom_components.nestquest.store import async_get_db_path
|
||||||
|
|
||||||
db_path = await async_get_db_path(hass)
|
db_path = await async_get_db_path(hass)
|
||||||
pre = NestQuestDatabase(hass)
|
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
await pre.open(db_path)
|
await pre.open(db_path)
|
||||||
await apply_migrations(pre)
|
await apply_migrations(pre)
|
||||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
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) == []
|
).list_by_date_range(child_id, beyond, beyond_end) == []
|
||||||
|
|
||||||
|
|
||||||
def test_configured_horizon_days_validates_and_falls_back() -> None:
|
async def test_setup_succeeds_with_fully_malformed_options(
|
||||||
"""The configured horizon is validated; a malformed value uses the default."""
|
hass, make_entry
|
||||||
import custom_components.nestquest as nestquest
|
) -> None:
|
||||||
|
"""A fully malformed options dict falls back per field and setup succeeds.
|
||||||
|
|
||||||
assert nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 5}) == 5
|
``horizon_days=True``, ``day_rollover_time='99:99'`` and
|
||||||
assert nestquest._configured_horizon_days({}) == DEFAULT_HORIZON_DAYS
|
``morning_summary_time='25:00'`` are all invalid; the integration's
|
||||||
assert (
|
``from_options_resilient`` falls each back to its default, so setup
|
||||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: True})
|
SUCCEEDS, the day-rollover listener registers at the default hour 0
|
||||||
== DEFAULT_HORIZON_DAYS
|
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 (
|
assert await async_setup_entry(hass, entry) is True
|
||||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: "5"})
|
|
||||||
== DEFAULT_HORIZON_DAYS
|
# The day-rollover listener fell back to the default 00:00.
|
||||||
)
|
registration = hass.time_change.registrations[0]
|
||||||
assert (
|
assert registration["hour"] == 0
|
||||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 0})
|
assert registration["minute"] == 0
|
||||||
== DEFAULT_HORIZON_DAYS
|
|
||||||
|
# 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(
|
async def test_regenerate_service_registered_and_materializes(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path):
|
async def _prepare(path):
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
return database
|
return database
|
||||||
@@ -1039,7 +1039,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight(
|
|||||||
clock["reads"] += 1
|
clock["reads"] += 1
|
||||||
return day0 if clock["reads"] == 1 else day1
|
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)
|
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:
|
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
|
# Give each materialize run a DISTINCT batch stamp: production upserts
|
||||||
# rewrite generated_at on every re-run, so a shared pinned stamp would
|
# 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
|
stamps["n"] += 1
|
||||||
return f"2026-06-01T00:00:{stamps['n']:02d}+00:00"
|
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)
|
monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp)
|
||||||
|
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
|
|
||||||
def _open_db(path) -> NestQuestDatabase:
|
def _open_db(path) -> NestQuestDatabase:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
_run(database.open(path))
|
_run(database.open(path))
|
||||||
return database
|
return database
|
||||||
|
|
||||||
@@ -707,7 +707,7 @@ def test_noop_run_logs_no_migration(caplog, tmp_path) -> None:
|
|||||||
try:
|
try:
|
||||||
_migrate(database)
|
_migrate(database)
|
||||||
caplog.clear()
|
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)
|
_migrate(database)
|
||||||
migrated = [
|
migrated = [
|
||||||
r for r in caplog.records if "migrated" in r.getMessage().lower()
|
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:
|
try:
|
||||||
_migrate(database)
|
_migrate(database)
|
||||||
caplog.clear()
|
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)
|
_migrate(database)
|
||||||
assert any(
|
assert any(
|
||||||
f"already at version {len(MIGRATIONS)}" in r.getMessage()
|
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] = {}
|
results: dict[str, int] = {}
|
||||||
|
|
||||||
async def _main(tmp_name: str) -> None:
|
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)
|
await database.open(tmp_name)
|
||||||
try:
|
try:
|
||||||
# Start A, yield so it acquires the lock and enters its
|
# Start A, yield so it acquires the lock and enters its
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ def _open_allowlist_db(tmp_path, name):
|
|||||||
|
|
||||||
hass = MagicMock()
|
hass = MagicMock()
|
||||||
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
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(database.open(tmp_path / name))
|
||||||
_run(apply_migrations(database))
|
_run(apply_migrations(database))
|
||||||
return database
|
return database
|
||||||
|
|||||||
+10
-2
@@ -364,11 +364,19 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_module_uses_no_iso_week_functions() -> 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
|
from pathlib import Path
|
||||||
|
|
||||||
source = Path(
|
source = Path(
|
||||||
__import__("custom_components.nestquest.presence", fromlist=["__file__"]).__file__
|
__import__("custom_components.nestquest.core.presence",
|
||||||
|
fromlist=["__file__"]).__file__
|
||||||
).read_text()
|
).read_text()
|
||||||
assert "isocalendar" not in source
|
assert "isocalendar" not in source
|
||||||
assert "isoweek" not in source
|
assert "isoweek" not in source
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path) -> tuple:
|
async def _prepare(path) -> tuple:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
children = ChildrenDao(database)
|
children = ChildrenDao(database)
|
||||||
@@ -1396,7 +1396,7 @@ def test_set_quest_definition_active_concurrent_opposite_transitions_return_own(
|
|||||||
gate_open = asyncio.Event()
|
gate_open = asyncio.Event()
|
||||||
a_started = asyncio.Event()
|
a_started = asyncio.Event()
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
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")
|
await database.open(tmp_path / "active-race.db")
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ def test_recurrence_module_imports_only_stdlib() -> None:
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
source = Path(
|
source = Path(
|
||||||
__import__("custom_components.nestquest.recurrence",
|
__import__("custom_components.nestquest.core.recurrence",
|
||||||
fromlist=["__file__"]).__file__
|
fromlist=["__file__"]).__file__
|
||||||
).read_text()
|
).read_text()
|
||||||
offenders = _scan_forbidden_imports(source)
|
offenders = _scan_forbidden_imports(source)
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import (
|
|||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
_month_end,
|
|
||||||
occurs_on,
|
occurs_on,
|
||||||
occurrences_between,
|
occurrences_between,
|
||||||
)
|
)
|
||||||
|
from custom_components.nestquest.core.recurrence import _month_end
|
||||||
|
|
||||||
|
|
||||||
def _d(date: str) -> datetime.date:
|
def _d(date: str) -> datetime.date:
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import (
|
|||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
_nth_weekday_of_month,
|
|
||||||
occurs_on,
|
occurs_on,
|
||||||
occurrences_between,
|
occurrences_between,
|
||||||
)
|
)
|
||||||
|
from custom_components.nestquest.core.recurrence import _nth_weekday_of_month
|
||||||
|
|
||||||
|
|
||||||
def _d(date: str) -> datetime.date:
|
def _d(date: str) -> datetime.date:
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
|
|
||||||
def _open_db(path) -> NestQuestDatabase:
|
def _open_db(path) -> NestQuestDatabase:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
_run(database.open(path))
|
_run(database.open(path))
|
||||||
return database
|
return database
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
|
|
||||||
def _open_db(path) -> NestQuestDatabase:
|
def _open_db(path) -> NestQuestDatabase:
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
_run(database.open(path))
|
_run(database.open(path))
|
||||||
return database
|
return database
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ def _open_db(path) -> NestQuestDatabase:
|
|||||||
async def _avalid_database(path) -> None:
|
async def _avalid_database(path) -> None:
|
||||||
"""Create a valid, migrated NestQuest database file (async, on the
|
"""Create a valid, migrated NestQuest database file (async, on the
|
||||||
running loop, for use inside pytest-asyncio tests)."""
|
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)
|
await database.open(path)
|
||||||
try:
|
try:
|
||||||
await apply_migrations(database)
|
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
|
"""Guardrail: no code path may delete or overwrite an existing
|
||||||
database file. Scans the package for unlink/rmtree/write on the
|
database file. Scans the package for unlink/rmtree/write on the
|
||||||
DB filename constant or *.db patterns.
|
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
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -459,7 +464,7 @@ def test_package_never_unlinks_or_truncates_db_files() -> None:
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
offenders: list[str] = []
|
offenders: list[str] = []
|
||||||
for py in sorted(package.glob("*.py")):
|
for py in sorted(package.rglob("*.py")):
|
||||||
if pattern.search(py.read_text()):
|
if pattern.search(py.read_text()):
|
||||||
offenders.append(py.name)
|
offenders.append(py.name)
|
||||||
assert offenders == [], (
|
assert offenders == [], (
|
||||||
|
|||||||
+30
-7
@@ -23,7 +23,11 @@ def _make_hass(config_dir: Path, execute: bool = True):
|
|||||||
return hass
|
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)
|
hass = _make_hass(Path("/config"), execute=False)
|
||||||
db_path = _run(store.async_get_db_path(hass))
|
db_path = _run(store.async_get_db_path(hass))
|
||||||
hass.config.path.assert_called_once_with(SQLITE_DB_FILENAME)
|
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()
|
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"
|
nested = tmp_path / "deep" / "nested"
|
||||||
hass = _make_hass(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))
|
db_path = _run(store.async_get_db_path(hass))
|
||||||
assert nested.is_dir()
|
assert nested.is_dir()
|
||||||
hass.async_add_executor_job.assert_called_once()
|
assert len(to_thread_calls) == 1, (
|
||||||
args, kwargs = hass.async_add_executor_job.call_args
|
f"expected one asyncio.to_thread call, got {len(to_thread_calls)}"
|
||||||
fn, fn_args = args[0], args[1:]
|
)
|
||||||
|
fn, fn_args, fn_kwargs = to_thread_calls[0]
|
||||||
assert callable(fn)
|
assert callable(fn)
|
||||||
assert fn is store._ensure_dir
|
assert fn is store._ensure_dir
|
||||||
assert fn_args == (nested,)
|
assert fn_args == (nested,)
|
||||||
assert kwargs == {}
|
assert fn_kwargs == {}
|
||||||
assert db_path.parent == nested
|
assert db_path.parent == nested
|
||||||
assert db_path.parent.is_dir()
|
assert db_path.parent.is_dir()
|
||||||
assert not db_path.exists()
|
assert not db_path.exists()
|
||||||
@@ -180,7 +202,8 @@ def test_no_file_creation_calls_in_store() -> None:
|
|||||||
assert "open(" not in source
|
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)
|
hass = _make_hass(Path("/config"), execute=False)
|
||||||
assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME
|
assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME
|
||||||
|
|
||||||
|
|||||||
+19
-34
@@ -13,7 +13,9 @@ import datetime
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from conftest import freeze_nestquest_clock
|
||||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
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_children import ChildrenDao
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
@@ -38,7 +40,7 @@ def _make_hass_mock():
|
|||||||
|
|
||||||
|
|
||||||
async def _prepare(path):
|
async def _prepare(path):
|
||||||
database = NestQuestDatabase(_make_hass_mock())
|
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||||
await database.open(path)
|
await database.open(path)
|
||||||
await apply_migrations(database)
|
await apply_migrations(database)
|
||||||
return database
|
return database
|
||||||
@@ -53,35 +55,10 @@ def _freeze_clock(
|
|||||||
) -> tuple[datetime.datetime, list]:
|
) -> tuple[datetime.datetime, list]:
|
||||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||||
|
|
||||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched, so
|
Thin wrapper over :func:`conftest.freeze_nestquest_clock` kept for the
|
||||||
the generation path's "today" reads this instant (localized to whatever
|
``(frozen, requested)`` return shape the timezone assertions read.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
import custom_components.nestquest as nestquest
|
return freeze_nestquest_clock(monkeypatch, instant)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_requested_ha_timezone(requested, time_zone: str) -> None:
|
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
|
import custom_components.nestquest as nestquest
|
||||||
|
|
||||||
child_id = await _seed_daily(database, local_today.isoformat())
|
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(
|
records = await QuestInstancesDao(database).list_by_date_range(
|
||||||
child_id,
|
child_id,
|
||||||
local_today.isoformat(),
|
local_today.isoformat(),
|
||||||
@@ -187,7 +166,9 @@ def test_horizon_uses_local_date_not_utc_when_host_ahead(
|
|||||||
import custom_components.nestquest as nestquest
|
import custom_components.nestquest as nestquest
|
||||||
|
|
||||||
child_id = await _seed_daily(database, local_today.isoformat())
|
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(
|
records = await QuestInstancesDao(database).list_by_date_range(
|
||||||
child_id,
|
child_id,
|
||||||
local_today.isoformat(),
|
local_today.isoformat(),
|
||||||
@@ -231,7 +212,9 @@ def test_horizon_resolves_spring_forward_day_without_off_by_one(
|
|||||||
requested_batches.append(requested)
|
requested_batches.append(requested)
|
||||||
local_today = instant.date()
|
local_today = instant.date()
|
||||||
child_id = await _seed_daily(database, local_today.isoformat())
|
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(
|
records = await QuestInstancesDao(database).list_by_date_range(
|
||||||
child_id,
|
child_id,
|
||||||
local_today.isoformat(),
|
local_today.isoformat(),
|
||||||
@@ -280,7 +263,9 @@ def test_horizon_resolves_fall_back_day_without_off_by_one(
|
|||||||
requested_batches.append(requested)
|
requested_batches.append(requested)
|
||||||
local_today = instant.date()
|
local_today = instant.date()
|
||||||
child_id = await _seed_daily(database, local_today.isoformat())
|
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(
|
records = await QuestInstancesDao(database).list_by_date_range(
|
||||||
child_id,
|
child_id,
|
||||||
local_today.isoformat(),
|
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
|
business operations must regenerate against the pinned date and keep
|
||||||
that first local day, proving the threaded ``today`` wins.
|
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)
|
pinned = datetime.date(2026, 1, 14)
|
||||||
host_today = datetime.date(2026, 1, 15)
|
host_today = datetime.date(2026, 1, 15)
|
||||||
|
|||||||
Reference in New Issue
Block a user