Feature 15: Domain Core Extraction
Feature 15: Domain Core Extraction (storage, engines/business, settings, no-HA smoke test)
This commit was merged in pull request #146.
This commit is contained in:
@@ -1,5 +1,28 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Storage layer extraction (task d0b691d8):** the SQLite storage layer
|
||||
(schema, migrations, db, store, const, recurrence, DAOs) is bundled
|
||||
under `custom_components/nestquest/core/`, with the integration's
|
||||
original module names preserved as re-export shims.
|
||||
- **Logger namespace change (breaking for logging configuration):** the
|
||||
migrations runner now logs under
|
||||
`custom_components.nestquest.core.migrations` instead of
|
||||
`custom_components.nestquest.migrations`. Users who tuned the old
|
||||
logger name (e.g. in `logger.yaml` or `configuration.yaml`) must
|
||||
update the entry to keep that level, as the old name no longer
|
||||
receives migration log output.
|
||||
- **Additional logger namespace moves:** the children registry and the
|
||||
admin allowlist business layers now log under
|
||||
`custom_components.nestquest.core.children` and
|
||||
`custom_components.nestquest.core.admin_allowlist` respectively
|
||||
(moved with the engines/business-layer extraction, task 510c1f78).
|
||||
The parent `custom_components.nestquest` logger still catches both,
|
||||
so users who tune the parent rather than the leaf names are
|
||||
unaffected; only users who pinned a leaf logger name by its old
|
||||
spelling need to update.
|
||||
|
||||
## Version 0.5.0 — 2026-09-18
|
||||
|
||||
### Scope
|
||||
|
||||
@@ -18,19 +18,16 @@ from homeassistant.helpers.event import async_track_time_change
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .core.settings import NestQuestSettings
|
||||
from .frontend import async_register_frontend
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase
|
||||
from .db import NestQuestDatabase, make_database
|
||||
from .materialize import materialize as _materialize_run
|
||||
from .migrations import apply_migrations
|
||||
from .services import async_deregister_services, async_register_services
|
||||
@@ -174,7 +171,7 @@ async def _async_open_database(
|
||||
"""
|
||||
try:
|
||||
await _preflight_existing_file(hass, db_path)
|
||||
database = await NestQuestDatabase(hass).open(db_path)
|
||||
database = await make_database(hass).open(db_path)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except sqlite3.DatabaseError as err:
|
||||
@@ -240,7 +237,7 @@ class NestQuestRuntimeData:
|
||||
"""Runtime data stored on a NestQuest config entry."""
|
||||
|
||||
entry_id: str
|
||||
options: dict[str, Any]
|
||||
settings: NestQuestSettings
|
||||
database: NestQuestDatabase
|
||||
remove_update_listener: Callable[[], Any]
|
||||
remove_time_change_listener: Callable[[], Any]
|
||||
@@ -269,38 +266,10 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||
return owners
|
||||
|
||||
|
||||
def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
|
||||
"""Return the (hour, minute) the daily rollover fires at from ``options``.
|
||||
|
||||
The options flow validates ``day_rollover_time`` as a strict ``HH:MM``
|
||||
string, so a stored value never needs re-validation here; a missing key
|
||||
falls back to :data:`~.const.DEFAULT_DAY_ROLLOVER_TIME`.
|
||||
"""
|
||||
rollover = options.get(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME)
|
||||
hour, _, minute = rollover.partition(":")
|
||||
return int(hour), int(minute)
|
||||
|
||||
|
||||
def _configured_horizon_days(options: dict[str, Any]) -> int:
|
||||
"""Return the configured generation horizon, validated, else the default.
|
||||
|
||||
The options flow already validates ``horizon_days`` as an integer >= 1,
|
||||
but a stored ``entry.options`` dict could still carry a partial or
|
||||
hand-edited value. Re-checking here — and falling back to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS` on a missing, non-int or sub-1 value
|
||||
— keeps a malformed option from shrinking or exploding the materialized
|
||||
horizon.
|
||||
"""
|
||||
value = options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
return DEFAULT_HORIZON_DAYS
|
||||
return value
|
||||
|
||||
|
||||
async def _run_horizon_materialization(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
horizon_days: int = DEFAULT_HORIZON_DAYS,
|
||||
settings: NestQuestSettings,
|
||||
) -> None:
|
||||
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
|
||||
|
||||
@@ -311,23 +280,22 @@ async def _run_horizon_materialization(
|
||||
|
||||
This is the ONE generation path: the startup backfill, the daily rollover
|
||||
listener and the ``regenerate`` service all funnel through it, so there is
|
||||
no duplicated materialization logic. ``horizon_days`` sizes the window
|
||||
and defaults to :data:`~.const.DEFAULT_HORIZON_DAYS`; callers thread the
|
||||
entry's validated option value through.
|
||||
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
|
||||
silent default; every caller already passes the entry's validated
|
||||
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
|
||||
window itself comes from :meth:`settings.horizon_window` so the helper is
|
||||
real and shared (not recomputed inline here).
|
||||
"""
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
start_date = today.isoformat()
|
||||
end_date = (
|
||||
today + datetime.timedelta(days=horizon_days)
|
||||
).isoformat()
|
||||
await _materialize_run(database, start_date, end_date, today=today)
|
||||
start, end = settings.horizon_window(today)
|
||||
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
|
||||
|
||||
|
||||
def _register_day_rollover_listener(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
options: dict[str, Any],
|
||||
settings: NestQuestSettings,
|
||||
) -> Callable[[], Any]:
|
||||
"""Register the daily rollover listener and return its remover.
|
||||
|
||||
@@ -335,16 +303,15 @@ def _register_day_rollover_listener(
|
||||
first re-materializes the rolling horizon
|
||||
``[today, today + horizon_days]`` through the shared
|
||||
:func:`_run_horizon_materialization` path (the entry's configured,
|
||||
validated ``horizon_days``), then runs the Feature 11 missed sweep
|
||||
— announcing yesterday's still-open quests as missed AFTER today's
|
||||
instances exist, watermark-guarded so a re-fire of the listener the
|
||||
same night announces nothing twice.
|
||||
validated ``horizon_days`` carried on ``settings``), then runs the
|
||||
Feature 11 missed sweep — announcing yesterday's still-open quests
|
||||
as missed AFTER today's instances exist, watermark-guarded so a
|
||||
re-fire of the listener the same night announces nothing twice.
|
||||
"""
|
||||
hour, minute = _day_rollover_hour_minute(options)
|
||||
horizon_days = _configured_horizon_days(options)
|
||||
hour, minute = settings.day_rollover_hour_minute
|
||||
|
||||
async def _run_materialization(_now: datetime.datetime) -> None:
|
||||
await _run_horizon_materialization(hass, database, horizon_days)
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
await run_missed_sweep(hass, database)
|
||||
|
||||
return async_track_time_change(
|
||||
@@ -359,7 +326,9 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||
their own; they resolve whichever config entry's runtime data is
|
||||
currently live at call time, so none of them can hold a stale handle
|
||||
to a closed connection after another entry unloads. The record also
|
||||
carries that entry's options (e.g. the configured horizon).
|
||||
carries that entry's validated ``settings`` (e.g. the configured
|
||||
horizon) so service handlers read horizon days off it rather than
|
||||
re-reading HA config.
|
||||
"""
|
||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||
database = getattr(runtime_data, "database", None)
|
||||
@@ -379,9 +348,8 @@ def _make_regenerate_handler(hass: HomeAssistant):
|
||||
"live database; ignoring"
|
||||
)
|
||||
return
|
||||
horizon_days = _configured_horizon_days(runtime_data.options)
|
||||
await _run_horizon_materialization(
|
||||
hass, runtime_data.database, horizon_days
|
||||
hass, runtime_data.database, runtime_data.settings
|
||||
)
|
||||
|
||||
return _regenerate
|
||||
@@ -495,18 +463,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
context_user_id=context_user_id,
|
||||
owner_ids=owner_ids,
|
||||
)
|
||||
# Build the explicit settings object from entry.options ONCE: the
|
||||
# horizon materialization and the daily-rollover listener both
|
||||
# consume it, so core never reads HA config itself. A malformed
|
||||
# stored option falls back PER FIELD (via from_options_resilient)
|
||||
# so a single bad sibling option cannot reset the user's
|
||||
# horizon_days or day_rollover_time — the two fields core
|
||||
# consumes — to their defaults.
|
||||
settings = NestQuestSettings.from_options_resilient(entry.options)
|
||||
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
||||
remove_time_change_listener = _register_day_rollover_listener(
|
||||
hass, database, dict(entry.options)
|
||||
hass, database, settings
|
||||
)
|
||||
# Backfill any days the daily listener missed while the integration
|
||||
# was off (or freshly installed): run the one idempotent walk over
|
||||
# [today, today + horizon_days] once at setup, using the entry's
|
||||
# configured horizon. This runs through the SAME path as the daily
|
||||
# listener and regenerate service.
|
||||
await _run_horizon_materialization(
|
||||
hass, database, _configured_horizon_days(dict(entry.options))
|
||||
)
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
# The missed sweep runs at startup too (Feature 11): if HA was
|
||||
# down at the rollover time, the previous night's still-open
|
||||
# quests are announced as missed now instead of waiting for
|
||||
@@ -529,7 +503,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
options=dict(entry.options),
|
||||
settings=settings,
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
remove_time_change_listener=remove_time_change_listener,
|
||||
|
||||
@@ -1,153 +1,11 @@
|
||||
"""Business layer for the admin allowlist (Feature 03).
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
|
||||
|
||||
Sits between the options flow / Feature 09 service gate and the typed
|
||||
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
|
||||
Assistant user IDs — never usernames or display names (feature
|
||||
guardrail) — and an empty allowlist must fail closed: every function
|
||||
here refuses to write an empty allowlist, so the owner can never be
|
||||
accidentally locked out of admin operations by a bad save.
|
||||
|
||||
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
|
||||
in one write are rejected up front — they would silently map to one
|
||||
row and hide a caller bug. Writes replace the allowlist atomically:
|
||||
the diff (additions and removals) is computed and applied inside one
|
||||
transaction under the connection-scoped lock shared with the DAOs, so
|
||||
a concurrent reader never observes a half-applied sync.
|
||||
|
||||
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
|
||||
when it is EMPTY, so re-running setup never resurrects a deliberately
|
||||
narrowed list. The setup user (the HA user who completed the config
|
||||
flow, via the entry context) is added automatically so the owner is
|
||||
never locked out on first setup; a stored entry-data copy of the list
|
||||
re-seeds a lost database.
|
||||
The admin allowlist business layer lives in
|
||||
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .admin_allowlist import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from .dao_children import AdminUsersDao, _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""UTC second-precision stamp, matching the registry's policy."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_user_id(value: object) -> str:
|
||||
"""Return ``value`` trimmed; raise unless a non-empty string."""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"admin user id must be a string, got {value!r}"
|
||||
)
|
||||
trimmed = value.strip()
|
||||
if not trimmed:
|
||||
raise ValueError("admin user id must not be empty")
|
||||
return trimmed
|
||||
|
||||
|
||||
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
|
||||
"""Return the allowlisted HA user IDs in storage order."""
|
||||
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
|
||||
|
||||
|
||||
async def set_admin_ids(
|
||||
database: NestQuestDatabase, user_ids: list[str]
|
||||
) -> None:
|
||||
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
|
||||
|
||||
Refuses an empty list — an empty allowlist must fail closed, and
|
||||
the only way to reach one through this API would be removing the
|
||||
last admin, so that removal is refused here with a clear error.
|
||||
Refuses non-string, empty, or duplicate ids up front. The diff
|
||||
against the current allowlist (additions and removals) applies
|
||||
inside one transaction under the connection lock.
|
||||
"""
|
||||
if not isinstance(user_ids, list):
|
||||
raise ValueError("user_ids must be a list of HA user id strings")
|
||||
normalized: list[str] = []
|
||||
for value in user_ids:
|
||||
normalized.append(_validate_user_id(value))
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ValueError("user_ids must not contain duplicates")
|
||||
if not normalized:
|
||||
raise ValueError(
|
||||
"refusing to empty the admin allowlist: at least one admin "
|
||||
"must remain (an empty allowlist fails closed)"
|
||||
)
|
||||
dao = AdminUsersDao(database)
|
||||
async with _connection_lock(database):
|
||||
async with database.transaction():
|
||||
current = {row.ha_user_id for row in await dao.list()}
|
||||
target = set(normalized)
|
||||
for user_id in sorted(current - target):
|
||||
await dao.remove(user_id)
|
||||
for user_id in normalized:
|
||||
await dao.add(user_id, _now_stamp())
|
||||
|
||||
|
||||
async def is_admin(
|
||||
database: NestQuestDatabase, user_id: object
|
||||
) -> bool:
|
||||
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
|
||||
|
||||
True only for an allowlisted user id. Everything else is False:
|
||||
a non-allowlisted id, an unknown id, a None or non-string value
|
||||
(a service call with no user context), and an empty allowlist
|
||||
(nobody is admin rather than everybody). The Feature 09 gate
|
||||
wraps every admin-only service with this verdict; it never raises
|
||||
for malformed input — malformed means not admin.
|
||||
"""
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
return False
|
||||
return await AdminUsersDao(database).exists(user_id)
|
||||
|
||||
|
||||
async def seed_setup_admin(
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
stored_admin_ids: list[str] | None = None,
|
||||
context_user_id: str | None = None,
|
||||
owner_ids: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Seed an empty allowlist so the owner is never locked out.
|
||||
|
||||
Only acts when the allowlist is EMPTY. Preference order: the
|
||||
stored persisted copy of the list (the options flow's current
|
||||
selection, then the config flow's original — disaster recovery for
|
||||
a lost database), then the HA user who completed the config flow
|
||||
(``context_user_id``), then — when no caller identity survived at
|
||||
all — every Home Assistant OWNER account (``owner_ids``), so a
|
||||
first setup whose flow context lost the user id still seeds a
|
||||
working admin. Returns the allowlist after seeding. Non-empty
|
||||
allowlists are returned untouched — re-running setup must never
|
||||
resurrect a deliberately narrowed list.
|
||||
"""
|
||||
dao = AdminUsersDao(database)
|
||||
async with _connection_lock(database):
|
||||
current = [row.ha_user_id for row in await dao.list()]
|
||||
if current:
|
||||
return current
|
||||
candidates: list[str] = []
|
||||
if stored_admin_ids is not None:
|
||||
if not isinstance(stored_admin_ids, list):
|
||||
raise ValueError(
|
||||
"stored_admin_ids must be a list of HA user id strings"
|
||||
)
|
||||
candidates = [_validate_user_id(value) for value in stored_admin_ids]
|
||||
elif context_user_id is not None:
|
||||
candidates = [_validate_user_id(context_user_id)]
|
||||
elif owner_ids:
|
||||
if not isinstance(owner_ids, list):
|
||||
raise ValueError(
|
||||
"owner_ids must be a list of HA user id strings"
|
||||
)
|
||||
candidates = [_validate_user_id(value) for value in owner_ids]
|
||||
for user_id in candidates:
|
||||
await dao.add(user_id, _now_stamp())
|
||||
return [row.ha_user_id for row in await dao.list()]
|
||||
from .core.admin_allowlist import * # noqa: F401,F403
|
||||
|
||||
@@ -1,280 +1,11 @@
|
||||
"""Business layer for the children registry (Feature 03).
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
|
||||
|
||||
Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_children.ChildrenDao`: callers get validation, name
|
||||
policies and the duplicate-name warning here, and never talk to the
|
||||
DAO or SQL directly. Children are never hard-deleted — deactivation
|
||||
is a separate explicit operation (:func:`set_child_active`), so
|
||||
completion history keeps its references (feature guardrail).
|
||||
|
||||
Name policy: the display name is required and trimmed; an empty or
|
||||
whitespace-only name is rejected with ValueError. A duplicate display
|
||||
name is ALLOWED (two children may legitimately share a nickname) but
|
||||
logged as a warning so an accidental double-add is visible in the HA
|
||||
log. The duplicate check is case-insensitive and whitespace-normalised
|
||||
on both sides, because "ada" and "Ada " are the same name to a parent.
|
||||
|
||||
Colour and avatar are optional free-form strings, trimmed when present;
|
||||
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
|
||||
optional field back to NULL is deliberately rejected: the DAO's update
|
||||
writes only provided fields (it cannot express NULL), so the business
|
||||
layer refuses an explicit ``None`` instead of silently ignoring it.
|
||||
Timestamps follow the module timestamp policy: the business layer
|
||||
stamps UTC ISO-8601 at second precision so every row it creates is
|
||||
uniform.
|
||||
The children registry business layer lives in
|
||||
:mod:`custom_components.nestquest.core.children` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .children import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
||||
#: in :func:`edit_child`.
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_text(
|
||||
value: object, field: str, *, required: bool = False
|
||||
) -> str | None:
|
||||
"""Validate a free-text field and return it trimmed.
|
||||
|
||||
``None`` raises when ``required`` and otherwise returns None. A
|
||||
non-string value is rejected; an empty result is rejected for
|
||||
required fields (whitespace-only names are empty to a parent).
|
||||
"""
|
||||
if value is None:
|
||||
if required:
|
||||
raise ValueError(f"{field} is required")
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||
trimmed = value.strip()
|
||||
if required and not trimmed:
|
||||
raise ValueError(f"{field} is required")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _validate_sort_order(value: object) -> int:
|
||||
"""Reject non-int sort orders (bools included) before the DAO."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"sort_order must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools included) before any lookup.
|
||||
|
||||
SQLite binds Python bools as integers, so ``True`` would silently
|
||||
address child 1 and a float would round — malformed service input
|
||||
must never mutate the wrong profile.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def _warn_on_duplicate_name(
|
||||
database: NestQuestDatabase,
|
||||
display_name: str,
|
||||
*,
|
||||
exclude_child_id: int | None = None,
|
||||
) -> None:
|
||||
"""Log a warning when ``display_name`` duplicates an existing child.
|
||||
|
||||
Case-insensitive and whitespace-normalised on both sides. The
|
||||
duplicate is still allowed — this only makes it visible; the log
|
||||
line names both rows so an accidental double-add can be found.
|
||||
``exclude_child_id`` removes the child being edited from the scan,
|
||||
so an edit only warns when the result collides with a DIFFERENT
|
||||
child's name — including case-only edits, where the self-comparison
|
||||
would otherwise hide a real duplicate.
|
||||
"""
|
||||
lowered = display_name.casefold()
|
||||
for child in await ChildrenDao(database).list_all():
|
||||
if exclude_child_id is not None and child.id == exclude_child_id:
|
||||
continue
|
||||
if child.display_name.strip().casefold() == lowered:
|
||||
LOGGER.warning(
|
||||
"Child display name %r duplicates existing child %d "
|
||||
"(%r); allowed, but check this was intentional",
|
||||
display_name,
|
||||
child.id,
|
||||
child.display_name,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def create_child(
|
||||
database: NestQuestDatabase,
|
||||
display_name: str,
|
||||
*,
|
||||
colour: str | None = None,
|
||||
avatar_ref: str | None = None,
|
||||
sort_order: int = 0,
|
||||
) -> ChildRecord:
|
||||
"""Create a child profile and return the stored record.
|
||||
|
||||
The display name is required, trimmed, and rejected when empty.
|
||||
A duplicate display name (case-insensitive) is allowed but logged
|
||||
as a warning. The scan and the INSERT run inside the
|
||||
connection-scoped lock shared with the DAOs, so a concurrent
|
||||
create of the same normalised name queues behind the first insert
|
||||
and sees it — the duplicate still warns; it can never slip through
|
||||
unlogged.
|
||||
"""
|
||||
name = _validate_text(display_name, "display_name", required=True)
|
||||
assert name is not None
|
||||
colour_value = _validate_text(colour, "colour")
|
||||
avatar_value = _validate_text(avatar_ref, "avatar_ref")
|
||||
order = _validate_sort_order(sort_order)
|
||||
async with _connection_lock(database):
|
||||
await _warn_on_duplicate_name(database, name)
|
||||
created = await ChildrenDao(database).create(
|
||||
name,
|
||||
_now_stamp(),
|
||||
colour=colour_value,
|
||||
avatar_ref=avatar_value,
|
||||
sort_order=order,
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
async def edit_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
*,
|
||||
display_name: str | object = _UNSET,
|
||||
colour: str | None | object = _UNSET,
|
||||
avatar_ref: str | None | object = _UNSET,
|
||||
sort_order: int | object = _UNSET,
|
||||
) -> ChildRecord:
|
||||
"""Edit a child profile and return the updated record.
|
||||
|
||||
Arguments default to the module sentinel ``_UNSET`` meaning "leave
|
||||
this field alone". An explicitly passed ``None`` for an optional
|
||||
field raises: the DAO's update writes only provided fields and
|
||||
cannot express NULL, so a silent no-op would mask the caller's
|
||||
intent (see the module docstring). A provided display name is
|
||||
validated like create's; the duplicate scan excludes only the child
|
||||
being edited, so a case-only edit colliding with another child's
|
||||
normalised name still warns. Raises ValueError when the child does
|
||||
not exist.
|
||||
|
||||
Atomicity: the existence check, the duplicate scan, the UPDATE and
|
||||
the readback all run inside the connection-scoped lock shared with
|
||||
the DAOs, so another edit cannot slip between this call's UPDATE
|
||||
and its readback — the returned record is exactly the one this
|
||||
edit produced, not a later writer's.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
async with _connection_lock(database):
|
||||
dao = ChildrenDao(database)
|
||||
existing = await dao.get(child_id)
|
||||
if existing is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
updates: dict[str, object] = {}
|
||||
if display_name is not _UNSET:
|
||||
name = _validate_text(
|
||||
display_name, "display_name", required=True
|
||||
)
|
||||
assert name is not None
|
||||
await _warn_on_duplicate_name(
|
||||
database, name, exclude_child_id=child_id
|
||||
)
|
||||
updates["display_name"] = name
|
||||
if colour is not _UNSET:
|
||||
value = _validate_text(colour, "colour")
|
||||
if value is None:
|
||||
raise ValueError(
|
||||
"clearing colour is not supported; pass a replacement "
|
||||
"value or omit the field"
|
||||
)
|
||||
updates["colour"] = value
|
||||
if avatar_ref is not _UNSET:
|
||||
value = _validate_text(avatar_ref, "avatar_ref")
|
||||
if value is None:
|
||||
raise ValueError(
|
||||
"clearing avatar_ref is not supported; pass a "
|
||||
"replacement value or omit the field"
|
||||
)
|
||||
updates["avatar_ref"] = value
|
||||
if sort_order is not _UNSET:
|
||||
updates["sort_order"] = _validate_sort_order(sort_order)
|
||||
if updates:
|
||||
await dao.update(child_id, **updates)
|
||||
edited = await dao.get(child_id)
|
||||
assert edited is not None
|
||||
return edited
|
||||
|
||||
|
||||
async def set_child_active(
|
||||
database: NestQuestDatabase, child_id: int, is_active: bool
|
||||
) -> ChildRecord:
|
||||
"""Deactivate or reactivate a child; returns the updated record.
|
||||
|
||||
Deactivation hides the child from the panel without deleting their
|
||||
history (feature guardrail); this is the only removal path.
|
||||
``is_active`` must be a real bool: the DAO's ``int()`` would
|
||||
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||
state the caller never asked for. Raises ValueError when the
|
||||
child does not exist.
|
||||
|
||||
Atomicity: existence check, UPDATE and readback run inside the
|
||||
connection-scoped lock, so a concurrent opposite transition cannot
|
||||
make this call report the other caller's value.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
async with _connection_lock(database):
|
||||
dao = ChildrenDao(database)
|
||||
if await dao.get(child_id) is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
if not isinstance(is_active, bool):
|
||||
raise ValueError(
|
||||
f"is_active must be a real bool, got {is_active!r}"
|
||||
)
|
||||
await dao.set_active(child_id, is_active)
|
||||
updated = await dao.get(child_id)
|
||||
assert updated is not None
|
||||
return updated
|
||||
|
||||
|
||||
async def reorder_children(
|
||||
database: NestQuestDatabase, ordered_ids: list[int]
|
||||
) -> None:
|
||||
"""Rewrite the children's panel display order in one transaction.
|
||||
|
||||
``ordered_ids`` must be a complete permutation of the children
|
||||
table — every child id exactly once. A partial list (missing an
|
||||
existing child), an unknown id, or a duplicate is rejected with
|
||||
ValueError before any write, so the current order survives a
|
||||
rejected call. The DAO enforces this inside its own transaction
|
||||
(check and write are atomic under the connection lock); this
|
||||
wrapper additionally rejects non-integer ids up front, since
|
||||
``bool`` and floats would silently compare against stored integer
|
||||
ids at the SQL layer.
|
||||
"""
|
||||
for child_id in ordered_ids:
|
||||
_validate_child_id(child_id)
|
||||
await ChildrenDao(database).reorder(ordered_ids)
|
||||
|
||||
|
||||
async def list_children(
|
||||
database: NestQuestDatabase, *, active_only: bool = False
|
||||
) -> list[ChildRecord]:
|
||||
"""Return children in display order (sort_order, then id)."""
|
||||
dao = ChildrenDao(database)
|
||||
if active_only:
|
||||
return await dao.list_active()
|
||||
return await dao.list_all()
|
||||
from .core.children import * # noqa: F401,F403
|
||||
|
||||
@@ -1,549 +1,11 @@
|
||||
"""Business layer for the completion event log (Feature 08).
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package.
|
||||
|
||||
Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
|
||||
validation and the UTC occurred-at stamp here, and never talk to the
|
||||
DAO or SQL directly. This module is the only Feature-08 caller of
|
||||
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
||||
instance (no-op if already done) is :func:`complete_instance`;
|
||||
un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
||||
Instance current state is derived from the latest event via
|
||||
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
|
||||
no status column. ``missed`` is derived for past-due open instances
|
||||
(``due_date`` before HA-local ``today``) and is never stored as an
|
||||
event_type.
|
||||
|
||||
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
||||
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
||||
requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'``
|
||||
requires ``actor_child_id`` (the tapped profile) and forbids
|
||||
``actor_user_id``. Inverted shapes raise a field-naming ValueError
|
||||
before any write.
|
||||
|
||||
``occurred_at`` is stamped UTC ISO-8601 at second precision with an
|
||||
explicit ``+00:00`` offset, matching the DAO's one accepted shape so
|
||||
lexicographic range queries stay exact. ``was_on_time`` is required
|
||||
(True/False) for completed events; un-completions may pass None. There
|
||||
is no permission check here (that is Feature 09).
|
||||
The completion business layer lives in
|
||||
:mod:`custom_components.nestquest.core.completion` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .completion import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
|
||||
from .dao_children import _CONNECTION_LOCKS, _connection_lock
|
||||
from .dao_instances import (
|
||||
EVENT_COMPLETED,
|
||||
EVENT_UNCOMPLETED,
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
QuestInstanceRecord,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
def _stamp_occurred_at(now: datetime.datetime | None) -> str:
|
||||
"""Return a DAO-accepted UTC stamp for ``now``, or the current time."""
|
||||
if now is None:
|
||||
return _now_stamp()
|
||||
if not isinstance(now, datetime.datetime):
|
||||
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now must be timezone-aware")
|
||||
return now.astimezone(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
def _completion_moment(
|
||||
now: datetime.datetime | None,
|
||||
today: datetime.date | None,
|
||||
) -> tuple[datetime.date, datetime.time | None]:
|
||||
"""Return the HA-local completion date and optional wall-clock time.
|
||||
|
||||
``today`` is the HA-local calendar date when threaded (Feature 07
|
||||
style). ``now`` is a timezone-aware HA-local datetime; its wall
|
||||
clock is used as-is and is never converted to UTC. When ``today``
|
||||
is omitted the date comes from ``now``. When both are omitted the
|
||||
host calendar date is the Feature 07 fallback — never the UTC
|
||||
clock. Time-of-day is None unless ``now`` is supplied; comparing
|
||||
against ``due_time`` requires ``now``.
|
||||
"""
|
||||
if now is not None:
|
||||
if not isinstance(now, datetime.datetime):
|
||||
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now must be timezone-aware")
|
||||
if today is not None:
|
||||
completion_date = today
|
||||
elif now is not None:
|
||||
completion_date = now.date()
|
||||
else:
|
||||
completion_date = datetime.date.today()
|
||||
if now is None:
|
||||
return completion_date, None
|
||||
return completion_date, now.time()
|
||||
|
||||
|
||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
"""Return the threaded HA-local date, or the host calendar date."""
|
||||
if today is None:
|
||||
return datetime.date.today()
|
||||
return today
|
||||
|
||||
|
||||
def _derived_state(
|
||||
instance: QuestInstanceRecord,
|
||||
latest: CompletionEventRecord | None,
|
||||
today: datetime.date,
|
||||
) -> str:
|
||||
"""Return ``open``, ``done``, or ``missed`` from event plus due date.
|
||||
|
||||
A latest completed event is ``done`` even when past due. No events
|
||||
or a latest uncompleted event is ``open``, or ``missed`` when
|
||||
``due_date`` is before ``today``. ``missed`` is never an event_type.
|
||||
"""
|
||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||
if due_date < today:
|
||||
return "missed"
|
||||
return "open"
|
||||
if latest.event_type == EVENT_COMPLETED:
|
||||
return "done"
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
|
||||
|
||||
def derive_state(
|
||||
instance: QuestInstanceRecord,
|
||||
latest: CompletionEventRecord | None,
|
||||
today: datetime.date,
|
||||
) -> str:
|
||||
"""Public state derivation for the Feature 10 coordinator.
|
||||
|
||||
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
|
||||
``missed`` from the latest event plus the due date (``missed`` is
|
||||
derived, never an event_type). ``today`` is the caller-resolved
|
||||
HA-local date.
|
||||
"""
|
||||
return _derived_state(instance, latest, today)
|
||||
|
||||
|
||||
def _was_on_time(
|
||||
instance: QuestInstanceRecord,
|
||||
now: datetime.datetime | None,
|
||||
today: datetime.date | None,
|
||||
) -> bool:
|
||||
"""Return whether the completion moment is on time for ``instance``.
|
||||
|
||||
On ``due_date`` before ``due_time`` is on time; on ``due_date``
|
||||
after ``due_time``, or any later date, is late. With no
|
||||
``due_time``, on time if the completion date equals ``due_date``
|
||||
(or is earlier); late if after ``due_date``. Same-day
|
||||
``due_time`` comparison requires ``now``; UTC time-of-day is never
|
||||
substituted.
|
||||
"""
|
||||
completion_date, completion_time = _completion_moment(now, today)
|
||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||
if completion_date < due_date:
|
||||
return True
|
||||
if completion_date > due_date:
|
||||
return False
|
||||
if instance.due_time is None:
|
||||
return True
|
||||
if completion_time is None:
|
||||
raise ValueError("now is required to compare due_time")
|
||||
due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time()
|
||||
return completion_time <= due_time
|
||||
|
||||
|
||||
class _TaskReentrantLock:
|
||||
"""Shared connection lock the owning task may re-enter."""
|
||||
|
||||
def __init__(self, inner: asyncio.Lock) -> None:
|
||||
self._inner = inner
|
||||
self._task: asyncio.Task | None = None
|
||||
self._depth = 0
|
||||
|
||||
def locked(self) -> bool:
|
||||
return self._inner.locked()
|
||||
|
||||
async def acquire(self) -> bool:
|
||||
task = asyncio.current_task()
|
||||
if self._task is task:
|
||||
self._depth += 1
|
||||
return True
|
||||
await self._inner.acquire()
|
||||
self._task = task
|
||||
self._depth = 1
|
||||
return True
|
||||
|
||||
def release(self) -> None:
|
||||
if self._task is not asyncio.current_task():
|
||||
raise RuntimeError("lock released by a non-owner")
|
||||
self._depth -= 1
|
||||
if self._depth == 0:
|
||||
self._task = None
|
||||
self._inner.release()
|
||||
|
||||
async def __aenter__(self) -> _TaskReentrantLock:
|
||||
await self.acquire()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
self.release()
|
||||
|
||||
|
||||
def _reentrant_connection_lock(database) -> _TaskReentrantLock:
|
||||
"""Return the shared connection lock, made task-reentrant.
|
||||
|
||||
``CompletionEventsDao.append`` also acquires this lock, so
|
||||
check+append in :func:`complete_instance` and
|
||||
:func:`uncomplete_instance` must re-enter on the same task. The
|
||||
wrapper is stored in the shared dict so the DAO sees the same
|
||||
object.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _CONNECTION_LOCKS.get(key)
|
||||
if isinstance(lock, _TaskReentrantLock):
|
||||
return lock
|
||||
wrapped = _TaskReentrantLock(
|
||||
lock if lock is not None else asyncio.Lock()
|
||||
)
|
||||
_CONNECTION_LOCKS[key] = wrapped
|
||||
return wrapped
|
||||
|
||||
|
||||
def _validate_int_id(value: object, field: str) -> int:
|
||||
"""Reject non-int ids (bools included) before any lookup."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{field} must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_event_type(value: object) -> str:
|
||||
"""Return a schema-legal event type, or raise naming the field."""
|
||||
if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_actor(
|
||||
actor_source: object,
|
||||
actor_user_id: object,
|
||||
actor_child_id: object,
|
||||
) -> tuple[str, str | None, int | None]:
|
||||
"""Return a schema-legal actor triple, or raise naming the field.
|
||||
|
||||
``'user'`` requires a non-empty ``actor_user_id`` string and forbids
|
||||
``actor_child_id``. ``'panel'`` requires an integer
|
||||
``actor_child_id`` and forbids ``actor_user_id``. Any other
|
||||
``actor_source`` (including ``'service'``) is rejected.
|
||||
"""
|
||||
if actor_source not in ("user", "panel"):
|
||||
raise ValueError(
|
||||
f"actor_source must be 'user' or 'panel', got {actor_source!r}"
|
||||
)
|
||||
if actor_source == "user":
|
||||
if not isinstance(actor_user_id, str) or not actor_user_id.strip():
|
||||
raise ValueError("actor_source 'user' requires actor_user_id")
|
||||
if actor_child_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'user' must not carry actor_child_id"
|
||||
)
|
||||
return "user", actor_user_id.strip(), None
|
||||
if actor_user_id is not None:
|
||||
raise ValueError("actor_source 'panel' must not carry actor_user_id")
|
||||
if actor_child_id is None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' requires actor_child_id"
|
||||
)
|
||||
return (
|
||||
"panel",
|
||||
None,
|
||||
_validate_int_id(actor_child_id, "actor_child_id"),
|
||||
)
|
||||
|
||||
|
||||
def _validate_was_on_time(
|
||||
event_type: str, was_on_time: object
|
||||
) -> bool | None:
|
||||
"""Return a real bool or None; completed events require True/False."""
|
||||
if was_on_time is not None and type(was_on_time) is not bool:
|
||||
raise ValueError(
|
||||
f"was_on_time must be True, False or None, got {was_on_time!r}"
|
||||
)
|
||||
if event_type == EVENT_COMPLETED and was_on_time is None:
|
||||
raise ValueError(
|
||||
"a completed event requires was_on_time (True/False)"
|
||||
)
|
||||
return was_on_time
|
||||
|
||||
|
||||
async def append_event(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
child_id: int,
|
||||
event_type: str,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
was_on_time: bool | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
) -> CompletionEventRecord:
|
||||
"""Append one completion or un-completion event and return the row.
|
||||
|
||||
Validates actor shapes and the completed-event on-time requirement
|
||||
before calling :meth:`CompletionEventsDao.append`. ``occurred_at``
|
||||
is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
child_id = _validate_int_id(child_id, "child_id")
|
||||
event_type = _validate_event_type(event_type)
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
was_on_time = _validate_was_on_time(event_type, was_on_time)
|
||||
occurred_at = _stamp_occurred_at(now)
|
||||
return await CompletionEventsDao(database).append(
|
||||
instance_id,
|
||||
child_id,
|
||||
event_type,
|
||||
actor_source,
|
||||
occurred_at,
|
||||
was_on_time,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
)
|
||||
|
||||
|
||||
async def instance_state(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> str:
|
||||
"""Return ``open``, ``done``, or ``missed`` from the latest event.
|
||||
|
||||
No events, or a latest event of uncompleted, is ``open`` unless
|
||||
``due_date`` is before ``today`` (HA-local, threaded), in which
|
||||
case it is ``missed``. A latest event of completed is ``done``
|
||||
even when past due. ``missed`` is never stored as an event_type.
|
||||
Unknown ``instance_id`` raises ValueError naming the field.
|
||||
Existence and the latest event are read inside the connection lock
|
||||
so the answer is one snapshot.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
today_date = _resolve_today(today)
|
||||
async with _connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
return _derived_state(instance, latest, today_date)
|
||||
|
||||
|
||||
class CompletionResult(str):
|
||||
"""The derived instance state, carrying the append verdict.
|
||||
|
||||
A ``str`` subclass so every existing ``result == "done"`` contract
|
||||
keeps working; ``appended`` reports whether THIS call actually
|
||||
appended an event (decided under the same lock as the append), so
|
||||
a caller — the Feature 09 service path firing bus events —
|
||||
announces a transition exactly once even under a concurrent
|
||||
duplicate call where the loser's write is a no-op. A completion
|
||||
also carries the ``was_on_time`` verdict computed under that same
|
||||
lock, so the event payload never re-reads mutable latest state a
|
||||
racing reversal could have replaced.
|
||||
"""
|
||||
|
||||
appended: bool
|
||||
was_on_time: bool | None
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
state: str,
|
||||
*,
|
||||
appended: bool,
|
||||
was_on_time: bool | None = None,
|
||||
) -> "CompletionResult":
|
||||
obj = super().__new__(cls, state)
|
||||
obj.appended = appended
|
||||
obj.was_on_time = was_on_time
|
||||
return obj
|
||||
|
||||
|
||||
async def complete_instance(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> CompletionResult:
|
||||
"""Append a completed event unless the instance is already done.
|
||||
|
||||
Returns the derived state ``done``; ``appended`` reports whether
|
||||
THIS call appended (False on an already-done no-op). Unknown
|
||||
``instance_id`` raises ValueError naming the field. Existence,
|
||||
latest-event, and the append run under one connection lock so a
|
||||
concurrent complete cannot double-append.
|
||||
|
||||
``was_on_time`` is computed from the HA-local completion moment
|
||||
(threaded ``now`` / ``today``) against the instance ``due_date``
|
||||
and optional ``due_time``. ``now`` is the timezone-aware HA-local
|
||||
datetime; its wall clock is never converted to UTC. Same-day
|
||||
``due_time`` comparison requires ``now``.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
async with _reentrant_connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
if latest is not None and latest.event_type == EVENT_COMPLETED:
|
||||
return CompletionResult("done", appended=False)
|
||||
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
# Computed under the completion lock and carried on the result:
|
||||
# the bus event must report THIS completion's timing, never a
|
||||
# re-read of the latest event a racing reversal could replace.
|
||||
on_time = _was_on_time(instance, now, today)
|
||||
await append_event(
|
||||
database,
|
||||
instance.id,
|
||||
instance.child_id,
|
||||
EVENT_COMPLETED,
|
||||
actor_source=actor_source,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
was_on_time=on_time,
|
||||
now=now,
|
||||
)
|
||||
return CompletionResult("done", appended=True, was_on_time=on_time)
|
||||
|
||||
|
||||
async def uncomplete_instance(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> CompletionResult:
|
||||
"""Append an uncompleted event unless the instance is already open.
|
||||
|
||||
Returns the derived state ``open``, or ``missed`` when the instance
|
||||
is past due (``due_date`` before HA-local ``today``); ``appended``
|
||||
reports whether THIS call appended (False on an already-open
|
||||
no-op). Unknown ``instance_id`` raises ValueError naming the
|
||||
field. Existence, latest-event, and the append run under one
|
||||
connection lock so a concurrent uncomplete cannot double-append.
|
||||
The original completed row is not modified.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
today_date = _resolve_today(today)
|
||||
async with _reentrant_connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||
return CompletionResult(
|
||||
_derived_state(instance, latest, today_date),
|
||||
appended=False,
|
||||
)
|
||||
if latest.event_type != EVENT_COMPLETED:
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
latest = await append_event(
|
||||
database,
|
||||
instance.id,
|
||||
instance.child_id,
|
||||
EVENT_UNCOMPLETED,
|
||||
actor_source=actor_source,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
was_on_time=None,
|
||||
now=now,
|
||||
)
|
||||
return CompletionResult(
|
||||
_derived_state(instance, latest, today_date), appended=True
|
||||
)
|
||||
|
||||
|
||||
async def list_missed_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's missed instances due in the closed date range.
|
||||
|
||||
Missed means the latest event state is open and ``due_date`` is
|
||||
before ``today`` (HA-local, threaded). Uses
|
||||
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
|
||||
Never appends a missed event. Instances and latest events are
|
||||
read inside the connection lock so the list is one snapshot.
|
||||
"""
|
||||
child_id = _validate_int_id(child_id, "child_id")
|
||||
today_date = _resolve_today(today)
|
||||
async with _connection_lock(database):
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, range_start, range_end
|
||||
)
|
||||
events = CompletionEventsDao(database)
|
||||
missed: list[QuestInstanceRecord] = []
|
||||
for instance in instances:
|
||||
latest = await events.get_latest_for_instance(instance.id)
|
||||
if _derived_state(instance, latest, today_date) == "missed":
|
||||
missed.append(instance)
|
||||
return missed
|
||||
from .core.completion import * # noqa: F401,F403
|
||||
|
||||
@@ -1,119 +1,10 @@
|
||||
"""Constants for the NestQuest integration."""
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.const` for the integration package.
|
||||
|
||||
The canonical constants live in :mod:`custom_components.nestquest.core.const` (the storage layer
|
||||
extracted in task d0b691d8); this module re-exports them so existing
|
||||
``from .const import ...`` references inside the integration keep
|
||||
working without touching every import line.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
DOMAIN = "nestquest"
|
||||
LOGGER_NAME = "custom_components.nestquest"
|
||||
LOGGER = logging.getLogger(LOGGER_NAME)
|
||||
|
||||
# Platforms set up per config entry (Feature 10): the per-child day
|
||||
# sensors and binary sensors.
|
||||
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||
|
||||
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
|
||||
# panel and admin cards listen for these; Feature 10 fires completed,
|
||||
# uncompleted, and child_day_complete from the service paths, and
|
||||
# Feature 11's nightly sweep fires quest_missed.
|
||||
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
|
||||
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
|
||||
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
|
||||
|
||||
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||
#: plus the existing Feature 07 regenerate service).
|
||||
SERVICE_COMPLETE_QUEST = "complete_quest"
|
||||
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
|
||||
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
|
||||
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
|
||||
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
|
||||
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
|
||||
SERVICE_MANAGE_CHILD = "manage_child"
|
||||
SERVICE_REGENERATE = "regenerate"
|
||||
|
||||
DOMAIN_SERVICES: tuple[str, ...] = (
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
)
|
||||
|
||||
SQLITE_DB_FILENAME = "nestquest.db"
|
||||
|
||||
# Configuration keys and default values
|
||||
CONF_HORIZON_DAYS = "horizon_days"
|
||||
DEFAULT_HORIZON_DAYS = 14
|
||||
|
||||
CONF_DAY_ROLLOVER_TIME = "day_rollover_time"
|
||||
DEFAULT_DAY_ROLLOVER_TIME = "00:00"
|
||||
|
||||
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT = 300
|
||||
|
||||
# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only
|
||||
# services. Stored as IDs, never usernames (feature guardrail). The
|
||||
# database allowlist is authoritative; the config entry carries the same
|
||||
# list as a disaster-recovery copy so a lost database can be re-seeded.
|
||||
CONF_ADMIN_USER_IDS = "admin_user_ids"
|
||||
|
||||
# Shared coordinator refresh interval (Feature 10), in SECONDS. Every
|
||||
# entity reads one snapshot per refresh; completions push an immediate
|
||||
# refresh so this poll is only the fallback cadence.
|
||||
CONF_UPDATE_INTERVAL = "update_interval"
|
||||
DEFAULT_UPDATE_INTERVAL = 300
|
||||
MIN_UPDATE_INTERVAL = 30
|
||||
|
||||
# Notification automations (Feature 11). The notify target is a free-text
|
||||
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
|
||||
# household fills in — never a hard-coded personal target; the times are
|
||||
# strict HH:MM local; each of the four automations has an independent
|
||||
# enable toggle. Per the owner-settled resolution the end-of-day report
|
||||
# defaults to 20:00 — deliberately BEFORE the midnight missed sweep —
|
||||
# and never defaults to the rollover time.
|
||||
CONF_NOTIFY_TARGET = "notify_target"
|
||||
CONF_MORNING_SUMMARY_TIME = "morning_summary_time"
|
||||
DEFAULT_MORNING_SUMMARY_TIME = "08:00"
|
||||
CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time"
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME = "15:00"
|
||||
CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time"
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME = "20:00"
|
||||
CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled"
|
||||
CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
|
||||
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
|
||||
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
||||
DEFAULT_AUTOMATION_ENABLED = True
|
||||
|
||||
# Quest windows (D-008): a definition may declare several day windows;
|
||||
# each produces its own instance per day and groups the panel's Quest
|
||||
# Log into three columns. The clock ranges are stored here so they stay
|
||||
# configurable later. Boundaries: a window's end equals the next
|
||||
# window's start; classification (Feature 07 materialisation) treats a
|
||||
# due time exactly on the boundary as belonging to the LATER window, so
|
||||
# 12:00 is afternoon (not morning) and 17:00 is evening (not
|
||||
# afternoon). Times after 21:00 belong to no window.
|
||||
WINDOW_MORNING = "morning"
|
||||
WINDOW_AFTERNOON = "afternoon"
|
||||
WINDOW_EVENING = "evening"
|
||||
|
||||
QUEST_WINDOWS: tuple[str, ...] = (
|
||||
WINDOW_MORNING,
|
||||
WINDOW_AFTERNOON,
|
||||
WINDOW_EVENING,
|
||||
)
|
||||
|
||||
#: Inclusive "HH:MM" bounds per window, in 24-hour local time.
|
||||
WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = {
|
||||
WINDOW_MORNING: ("00:00", "11:59"),
|
||||
WINDOW_AFTERNOON: ("12:00", "17:00"),
|
||||
WINDOW_EVENING: ("17:00", "21:00"),
|
||||
}
|
||||
from .core.const import * # noqa: F401,F403
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""NestQuest storage layer.
|
||||
|
||||
A self-contained, Home-Assistant-free package holding the NestQuest
|
||||
SQLite storage layer: schema DDL, the versioned migration runner, the
|
||||
async connection wrapper, the path resolver, the domain constants and
|
||||
recurrence model, and the typed DAOs. Nothing in this package imports
|
||||
``homeassistant``; the integration wires it into HA at its own layer
|
||||
(constructing :class:`custom_components.nestquest.core.db.NestQuestDatabase`
|
||||
with an executor adapter backed by ``hass.async_add_executor_job``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Business layer for the admin allowlist (Feature 03).
|
||||
|
||||
Sits between the options flow / Feature 09 service gate and the typed
|
||||
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
|
||||
Assistant user IDs — never usernames or display names (feature
|
||||
guardrail) — and an empty allowlist must fail closed: every function
|
||||
here refuses to write an empty allowlist, so the owner can never be
|
||||
accidentally locked out of admin operations by a bad save.
|
||||
|
||||
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
|
||||
in one write are rejected up front — they would silently map to one
|
||||
row and hide a caller bug. Writes replace the allowlist atomically:
|
||||
the diff (additions and removals) is computed and applied inside one
|
||||
transaction under the connection-scoped lock shared with the DAOs, so
|
||||
a concurrent reader never observes a half-applied sync.
|
||||
|
||||
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
|
||||
when it is EMPTY, so re-running setup never resurrects a deliberately
|
||||
narrowed list. The setup user (the HA user who completed the config
|
||||
flow, via the entry context) is added automatically so the owner is
|
||||
never locked out on first setup; a stored entry-data copy of the list
|
||||
re-seeds a lost database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from .dao_children import AdminUsersDao, _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""UTC second-precision stamp, matching the registry's policy."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_user_id(value: object) -> str:
|
||||
"""Return ``value`` trimmed; raise unless a non-empty string."""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"admin user id must be a string, got {value!r}"
|
||||
)
|
||||
trimmed = value.strip()
|
||||
if not trimmed:
|
||||
raise ValueError("admin user id must not be empty")
|
||||
return trimmed
|
||||
|
||||
|
||||
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
|
||||
"""Return the allowlisted HA user IDs in storage order."""
|
||||
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
|
||||
|
||||
|
||||
async def set_admin_ids(
|
||||
database: NestQuestDatabase, user_ids: list[str]
|
||||
) -> None:
|
||||
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
|
||||
|
||||
Refuses an empty list — an empty allowlist must fail closed, and
|
||||
the only way to reach one through this API would be removing the
|
||||
last admin, so that removal is refused here with a clear error.
|
||||
Refuses non-string, empty, or duplicate ids up front. The diff
|
||||
against the current allowlist (additions and removals) applies
|
||||
inside one transaction under the connection lock.
|
||||
"""
|
||||
if not isinstance(user_ids, list):
|
||||
raise ValueError("user_ids must be a list of HA user id strings")
|
||||
normalized: list[str] = []
|
||||
for value in user_ids:
|
||||
normalized.append(_validate_user_id(value))
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ValueError("user_ids must not contain duplicates")
|
||||
if not normalized:
|
||||
raise ValueError(
|
||||
"refusing to empty the admin allowlist: at least one admin "
|
||||
"must remain (an empty allowlist fails closed)"
|
||||
)
|
||||
dao = AdminUsersDao(database)
|
||||
async with _connection_lock(database):
|
||||
async with database.transaction():
|
||||
current = {row.ha_user_id for row in await dao.list()}
|
||||
target = set(normalized)
|
||||
for user_id in sorted(current - target):
|
||||
await dao.remove(user_id)
|
||||
for user_id in normalized:
|
||||
await dao.add(user_id, _now_stamp())
|
||||
|
||||
|
||||
async def is_admin(
|
||||
database: NestQuestDatabase, user_id: object
|
||||
) -> bool:
|
||||
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
|
||||
|
||||
True only for an allowlisted user id. Everything else is False:
|
||||
a non-allowlisted id, an unknown id, a None or non-string value
|
||||
(a service call with no user context), and an empty allowlist
|
||||
(nobody is admin rather than everybody). The Feature 09 gate
|
||||
wraps every admin-only service with this verdict; it never raises
|
||||
for malformed input — malformed means not admin.
|
||||
"""
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
return False
|
||||
return await AdminUsersDao(database).exists(user_id)
|
||||
|
||||
|
||||
async def seed_setup_admin(
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
stored_admin_ids: list[str] | None = None,
|
||||
context_user_id: str | None = None,
|
||||
owner_ids: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Seed an empty allowlist so the owner is never locked out.
|
||||
|
||||
Only acts when the allowlist is EMPTY. Preference order: the
|
||||
stored persisted copy of the list (the options flow's current
|
||||
selection, then the config flow's original — disaster recovery for
|
||||
a lost database), then the HA user who completed the config flow
|
||||
(``context_user_id``), then — when no caller identity survived at
|
||||
all — every Home Assistant OWNER account (``owner_ids``), so a
|
||||
first setup whose flow context lost the user id still seeds a
|
||||
working admin. Returns the allowlist after seeding. Non-empty
|
||||
allowlists are returned untouched — re-running setup must never
|
||||
resurrect a deliberately narrowed list.
|
||||
"""
|
||||
dao = AdminUsersDao(database)
|
||||
async with _connection_lock(database):
|
||||
current = [row.ha_user_id for row in await dao.list()]
|
||||
if current:
|
||||
return current
|
||||
candidates: list[str] = []
|
||||
if stored_admin_ids is not None:
|
||||
if not isinstance(stored_admin_ids, list):
|
||||
raise ValueError(
|
||||
"stored_admin_ids must be a list of HA user id strings"
|
||||
)
|
||||
candidates = [_validate_user_id(value) for value in stored_admin_ids]
|
||||
elif context_user_id is not None:
|
||||
candidates = [_validate_user_id(context_user_id)]
|
||||
elif owner_ids:
|
||||
if not isinstance(owner_ids, list):
|
||||
raise ValueError(
|
||||
"owner_ids must be a list of HA user id strings"
|
||||
)
|
||||
candidates = [_validate_user_id(value) for value in owner_ids]
|
||||
for user_id in candidates:
|
||||
await dao.add(user_id, _now_stamp())
|
||||
return [row.ha_user_id for row in await dao.list()]
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Business layer for the children registry (Feature 03).
|
||||
|
||||
Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_children.ChildrenDao`: callers get validation, name
|
||||
policies and the duplicate-name warning here, and never talk to the
|
||||
DAO or SQL directly. Children are never hard-deleted — deactivation
|
||||
is a separate explicit operation (:func:`set_child_active`), so
|
||||
completion history keeps its references (feature guardrail).
|
||||
|
||||
Name policy: the display name is required and trimmed; an empty or
|
||||
whitespace-only name is rejected with ValueError. A duplicate display
|
||||
name is ALLOWED (two children may legitimately share a nickname) but
|
||||
logged as a warning so an accidental double-add is visible in the HA
|
||||
log. The duplicate check is case-insensitive and whitespace-normalised
|
||||
on both sides, because "ada" and "Ada " are the same name to a parent.
|
||||
|
||||
Colour and avatar are optional free-form strings, trimmed when present;
|
||||
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
|
||||
optional field back to NULL is deliberately rejected: the DAO's update
|
||||
writes only provided fields (it cannot express NULL), so the business
|
||||
layer refuses an explicit ``None`` instead of silently ignoring it.
|
||||
Timestamps follow the module timestamp policy: the business layer
|
||||
stamps UTC ISO-8601 at second precision so every row it creates is
|
||||
uniform.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
||||
#: in :func:`edit_child`.
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_text(
|
||||
value: object, field: str, *, required: bool = False
|
||||
) -> str | None:
|
||||
"""Validate a free-text field and return it trimmed.
|
||||
|
||||
``None`` raises when ``required`` and otherwise returns None. A
|
||||
non-string value is rejected; an empty result is rejected for
|
||||
required fields (whitespace-only names are empty to a parent).
|
||||
"""
|
||||
if value is None:
|
||||
if required:
|
||||
raise ValueError(f"{field} is required")
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||
trimmed = value.strip()
|
||||
if required and not trimmed:
|
||||
raise ValueError(f"{field} is required")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _validate_sort_order(value: object) -> int:
|
||||
"""Reject non-int sort orders (bools included) before the DAO."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"sort_order must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools included) before any lookup.
|
||||
|
||||
SQLite binds Python bools as integers, so ``True`` would silently
|
||||
address child 1 and a float would round — malformed service input
|
||||
must never mutate the wrong profile.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def _warn_on_duplicate_name(
|
||||
database: NestQuestDatabase,
|
||||
display_name: str,
|
||||
*,
|
||||
exclude_child_id: int | None = None,
|
||||
) -> None:
|
||||
"""Log a warning when ``display_name`` duplicates an existing child.
|
||||
|
||||
Case-insensitive and whitespace-normalised on both sides. The
|
||||
duplicate is still allowed — this only makes it visible; the log
|
||||
line names both rows so an accidental double-add can be found.
|
||||
``exclude_child_id`` removes the child being edited from the scan,
|
||||
so an edit only warns when the result collides with a DIFFERENT
|
||||
child's name — including case-only edits, where the self-comparison
|
||||
would otherwise hide a real duplicate.
|
||||
"""
|
||||
lowered = display_name.casefold()
|
||||
for child in await ChildrenDao(database).list_all():
|
||||
if exclude_child_id is not None and child.id == exclude_child_id:
|
||||
continue
|
||||
if child.display_name.strip().casefold() == lowered:
|
||||
LOGGER.warning(
|
||||
"Child display name %r duplicates existing child %d "
|
||||
"(%r); allowed, but check this was intentional",
|
||||
display_name,
|
||||
child.id,
|
||||
child.display_name,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def create_child(
|
||||
database: NestQuestDatabase,
|
||||
display_name: str,
|
||||
*,
|
||||
colour: str | None = None,
|
||||
avatar_ref: str | None = None,
|
||||
sort_order: int = 0,
|
||||
) -> ChildRecord:
|
||||
"""Create a child profile and return the stored record.
|
||||
|
||||
The display name is required, trimmed, and rejected when empty.
|
||||
A duplicate display name (case-insensitive) is allowed but logged
|
||||
as a warning. The scan and the INSERT run inside the
|
||||
connection-scoped lock shared with the DAOs, so a concurrent
|
||||
create of the same normalised name queues behind the first insert
|
||||
and sees it — the duplicate still warns; it can never slip through
|
||||
unlogged.
|
||||
"""
|
||||
name = _validate_text(display_name, "display_name", required=True)
|
||||
assert name is not None
|
||||
colour_value = _validate_text(colour, "colour")
|
||||
avatar_value = _validate_text(avatar_ref, "avatar_ref")
|
||||
order = _validate_sort_order(sort_order)
|
||||
async with _connection_lock(database):
|
||||
await _warn_on_duplicate_name(database, name)
|
||||
created = await ChildrenDao(database).create(
|
||||
name,
|
||||
_now_stamp(),
|
||||
colour=colour_value,
|
||||
avatar_ref=avatar_value,
|
||||
sort_order=order,
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
async def edit_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
*,
|
||||
display_name: str | object = _UNSET,
|
||||
colour: str | None | object = _UNSET,
|
||||
avatar_ref: str | None | object = _UNSET,
|
||||
sort_order: int | object = _UNSET,
|
||||
) -> ChildRecord:
|
||||
"""Edit a child profile and return the updated record.
|
||||
|
||||
Arguments default to the module sentinel ``_UNSET`` meaning "leave
|
||||
this field alone". An explicitly passed ``None`` for an optional
|
||||
field raises: the DAO's update writes only provided fields and
|
||||
cannot express NULL, so a silent no-op would mask the caller's
|
||||
intent (see the module docstring). A provided display name is
|
||||
validated like create's; the duplicate scan excludes only the child
|
||||
being edited, so a case-only edit colliding with another child's
|
||||
normalised name still warns. Raises ValueError when the child does
|
||||
not exist.
|
||||
|
||||
Atomicity: the existence check, the duplicate scan, the UPDATE and
|
||||
the readback all run inside the connection-scoped lock shared with
|
||||
the DAOs, so another edit cannot slip between this call's UPDATE
|
||||
and its readback — the returned record is exactly the one this
|
||||
edit produced, not a later writer's.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
async with _connection_lock(database):
|
||||
dao = ChildrenDao(database)
|
||||
existing = await dao.get(child_id)
|
||||
if existing is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
updates: dict[str, object] = {}
|
||||
if display_name is not _UNSET:
|
||||
name = _validate_text(
|
||||
display_name, "display_name", required=True
|
||||
)
|
||||
assert name is not None
|
||||
await _warn_on_duplicate_name(
|
||||
database, name, exclude_child_id=child_id
|
||||
)
|
||||
updates["display_name"] = name
|
||||
if colour is not _UNSET:
|
||||
value = _validate_text(colour, "colour")
|
||||
if value is None:
|
||||
raise ValueError(
|
||||
"clearing colour is not supported; pass a replacement "
|
||||
"value or omit the field"
|
||||
)
|
||||
updates["colour"] = value
|
||||
if avatar_ref is not _UNSET:
|
||||
value = _validate_text(avatar_ref, "avatar_ref")
|
||||
if value is None:
|
||||
raise ValueError(
|
||||
"clearing avatar_ref is not supported; pass a "
|
||||
"replacement value or omit the field"
|
||||
)
|
||||
updates["avatar_ref"] = value
|
||||
if sort_order is not _UNSET:
|
||||
updates["sort_order"] = _validate_sort_order(sort_order)
|
||||
if updates:
|
||||
await dao.update(child_id, **updates)
|
||||
edited = await dao.get(child_id)
|
||||
assert edited is not None
|
||||
return edited
|
||||
|
||||
|
||||
async def set_child_active(
|
||||
database: NestQuestDatabase, child_id: int, is_active: bool
|
||||
) -> ChildRecord:
|
||||
"""Deactivate or reactivate a child; returns the updated record.
|
||||
|
||||
Deactivation hides the child from the panel without deleting their
|
||||
history (feature guardrail); this is the only removal path.
|
||||
``is_active`` must be a real bool: the DAO's ``int()`` would
|
||||
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||
state the caller never asked for. Raises ValueError when the
|
||||
child does not exist.
|
||||
|
||||
Atomicity: existence check, UPDATE and readback run inside the
|
||||
connection-scoped lock, so a concurrent opposite transition cannot
|
||||
make this call report the other caller's value.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
async with _connection_lock(database):
|
||||
dao = ChildrenDao(database)
|
||||
if await dao.get(child_id) is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
if not isinstance(is_active, bool):
|
||||
raise ValueError(
|
||||
f"is_active must be a real bool, got {is_active!r}"
|
||||
)
|
||||
await dao.set_active(child_id, is_active)
|
||||
updated = await dao.get(child_id)
|
||||
assert updated is not None
|
||||
return updated
|
||||
|
||||
|
||||
async def reorder_children(
|
||||
database: NestQuestDatabase, ordered_ids: list[int]
|
||||
) -> None:
|
||||
"""Rewrite the children's panel display order in one transaction.
|
||||
|
||||
``ordered_ids`` must be a complete permutation of the children
|
||||
table — every child id exactly once. A partial list (missing an
|
||||
existing child), an unknown id, or a duplicate is rejected with
|
||||
ValueError before any write, so the current order survives a
|
||||
rejected call. The DAO enforces this inside its own transaction
|
||||
(check and write are atomic under the connection lock); this
|
||||
wrapper additionally rejects non-integer ids up front, since
|
||||
``bool`` and floats would silently compare against stored integer
|
||||
ids at the SQL layer.
|
||||
"""
|
||||
for child_id in ordered_ids:
|
||||
_validate_child_id(child_id)
|
||||
await ChildrenDao(database).reorder(ordered_ids)
|
||||
|
||||
|
||||
async def list_children(
|
||||
database: NestQuestDatabase, *, active_only: bool = False
|
||||
) -> list[ChildRecord]:
|
||||
"""Return children in display order (sort_order, then id)."""
|
||||
dao = ChildrenDao(database)
|
||||
if active_only:
|
||||
return await dao.list_active()
|
||||
return await dao.list_all()
|
||||
@@ -0,0 +1,549 @@
|
||||
"""Business layer for the completion event log (Feature 08).
|
||||
|
||||
Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
|
||||
validation and the UTC occurred-at stamp here, and never talk to the
|
||||
DAO or SQL directly. This module is the only Feature-08 caller of
|
||||
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
||||
instance (no-op if already done) is :func:`complete_instance`;
|
||||
un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
||||
Instance current state is derived from the latest event via
|
||||
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
|
||||
no status column. ``missed`` is derived for past-due open instances
|
||||
(``due_date`` before HA-local ``today``) and is never stored as an
|
||||
event_type.
|
||||
|
||||
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
||||
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
||||
requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'``
|
||||
requires ``actor_child_id`` (the tapped profile) and forbids
|
||||
``actor_user_id``. Inverted shapes raise a field-naming ValueError
|
||||
before any write.
|
||||
|
||||
``occurred_at`` is stamped UTC ISO-8601 at second precision with an
|
||||
explicit ``+00:00`` offset, matching the DAO's one accepted shape so
|
||||
lexicographic range queries stay exact. ``was_on_time`` is required
|
||||
(True/False) for completed events; un-completions may pass None. There
|
||||
is no permission check here (that is Feature 09).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
|
||||
from .dao_children import _CONNECTION_LOCKS, _connection_lock
|
||||
from .dao_instances import (
|
||||
EVENT_COMPLETED,
|
||||
EVENT_UNCOMPLETED,
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
QuestInstanceRecord,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
def _stamp_occurred_at(now: datetime.datetime | None) -> str:
|
||||
"""Return a DAO-accepted UTC stamp for ``now``, or the current time."""
|
||||
if now is None:
|
||||
return _now_stamp()
|
||||
if not isinstance(now, datetime.datetime):
|
||||
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now must be timezone-aware")
|
||||
return now.astimezone(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
def _completion_moment(
|
||||
now: datetime.datetime | None,
|
||||
today: datetime.date | None,
|
||||
) -> tuple[datetime.date, datetime.time | None]:
|
||||
"""Return the HA-local completion date and optional wall-clock time.
|
||||
|
||||
``today`` is the HA-local calendar date when threaded (Feature 07
|
||||
style). ``now`` is a timezone-aware HA-local datetime; its wall
|
||||
clock is used as-is and is never converted to UTC. When ``today``
|
||||
is omitted the date comes from ``now``. When both are omitted the
|
||||
host calendar date is the Feature 07 fallback — never the UTC
|
||||
clock. Time-of-day is None unless ``now`` is supplied; comparing
|
||||
against ``due_time`` requires ``now``.
|
||||
"""
|
||||
if now is not None:
|
||||
if not isinstance(now, datetime.datetime):
|
||||
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now must be timezone-aware")
|
||||
if today is not None:
|
||||
completion_date = today
|
||||
elif now is not None:
|
||||
completion_date = now.date()
|
||||
else:
|
||||
completion_date = datetime.date.today()
|
||||
if now is None:
|
||||
return completion_date, None
|
||||
return completion_date, now.time()
|
||||
|
||||
|
||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
"""Return the threaded HA-local date, or the host calendar date."""
|
||||
if today is None:
|
||||
return datetime.date.today()
|
||||
return today
|
||||
|
||||
|
||||
def _derived_state(
|
||||
instance: QuestInstanceRecord,
|
||||
latest: CompletionEventRecord | None,
|
||||
today: datetime.date,
|
||||
) -> str:
|
||||
"""Return ``open``, ``done``, or ``missed`` from event plus due date.
|
||||
|
||||
A latest completed event is ``done`` even when past due. No events
|
||||
or a latest uncompleted event is ``open``, or ``missed`` when
|
||||
``due_date`` is before ``today``. ``missed`` is never an event_type.
|
||||
"""
|
||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||
if due_date < today:
|
||||
return "missed"
|
||||
return "open"
|
||||
if latest.event_type == EVENT_COMPLETED:
|
||||
return "done"
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
|
||||
|
||||
def derive_state(
|
||||
instance: QuestInstanceRecord,
|
||||
latest: CompletionEventRecord | None,
|
||||
today: datetime.date,
|
||||
) -> str:
|
||||
"""Public state derivation for the Feature 10 coordinator.
|
||||
|
||||
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
|
||||
``missed`` from the latest event plus the due date (``missed`` is
|
||||
derived, never an event_type). ``today`` is the caller-resolved
|
||||
HA-local date.
|
||||
"""
|
||||
return _derived_state(instance, latest, today)
|
||||
|
||||
|
||||
def _was_on_time(
|
||||
instance: QuestInstanceRecord,
|
||||
now: datetime.datetime | None,
|
||||
today: datetime.date | None,
|
||||
) -> bool:
|
||||
"""Return whether the completion moment is on time for ``instance``.
|
||||
|
||||
On ``due_date`` before ``due_time`` is on time; on ``due_date``
|
||||
after ``due_time``, or any later date, is late. With no
|
||||
``due_time``, on time if the completion date equals ``due_date``
|
||||
(or is earlier); late if after ``due_date``. Same-day
|
||||
``due_time`` comparison requires ``now``; UTC time-of-day is never
|
||||
substituted.
|
||||
"""
|
||||
completion_date, completion_time = _completion_moment(now, today)
|
||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||
if completion_date < due_date:
|
||||
return True
|
||||
if completion_date > due_date:
|
||||
return False
|
||||
if instance.due_time is None:
|
||||
return True
|
||||
if completion_time is None:
|
||||
raise ValueError("now is required to compare due_time")
|
||||
due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time()
|
||||
return completion_time <= due_time
|
||||
|
||||
|
||||
class _TaskReentrantLock:
|
||||
"""Shared connection lock the owning task may re-enter."""
|
||||
|
||||
def __init__(self, inner: asyncio.Lock) -> None:
|
||||
self._inner = inner
|
||||
self._task: asyncio.Task | None = None
|
||||
self._depth = 0
|
||||
|
||||
def locked(self) -> bool:
|
||||
return self._inner.locked()
|
||||
|
||||
async def acquire(self) -> bool:
|
||||
task = asyncio.current_task()
|
||||
if self._task is task:
|
||||
self._depth += 1
|
||||
return True
|
||||
await self._inner.acquire()
|
||||
self._task = task
|
||||
self._depth = 1
|
||||
return True
|
||||
|
||||
def release(self) -> None:
|
||||
if self._task is not asyncio.current_task():
|
||||
raise RuntimeError("lock released by a non-owner")
|
||||
self._depth -= 1
|
||||
if self._depth == 0:
|
||||
self._task = None
|
||||
self._inner.release()
|
||||
|
||||
async def __aenter__(self) -> _TaskReentrantLock:
|
||||
await self.acquire()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
self.release()
|
||||
|
||||
|
||||
def _reentrant_connection_lock(database) -> _TaskReentrantLock:
|
||||
"""Return the shared connection lock, made task-reentrant.
|
||||
|
||||
``CompletionEventsDao.append`` also acquires this lock, so
|
||||
check+append in :func:`complete_instance` and
|
||||
:func:`uncomplete_instance` must re-enter on the same task. The
|
||||
wrapper is stored in the shared dict so the DAO sees the same
|
||||
object.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _CONNECTION_LOCKS.get(key)
|
||||
if isinstance(lock, _TaskReentrantLock):
|
||||
return lock
|
||||
wrapped = _TaskReentrantLock(
|
||||
lock if lock is not None else asyncio.Lock()
|
||||
)
|
||||
_CONNECTION_LOCKS[key] = wrapped
|
||||
return wrapped
|
||||
|
||||
|
||||
def _validate_int_id(value: object, field: str) -> int:
|
||||
"""Reject non-int ids (bools included) before any lookup."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{field} must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_event_type(value: object) -> str:
|
||||
"""Return a schema-legal event type, or raise naming the field."""
|
||||
if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_actor(
|
||||
actor_source: object,
|
||||
actor_user_id: object,
|
||||
actor_child_id: object,
|
||||
) -> tuple[str, str | None, int | None]:
|
||||
"""Return a schema-legal actor triple, or raise naming the field.
|
||||
|
||||
``'user'`` requires a non-empty ``actor_user_id`` string and forbids
|
||||
``actor_child_id``. ``'panel'`` requires an integer
|
||||
``actor_child_id`` and forbids ``actor_user_id``. Any other
|
||||
``actor_source`` (including ``'service'``) is rejected.
|
||||
"""
|
||||
if actor_source not in ("user", "panel"):
|
||||
raise ValueError(
|
||||
f"actor_source must be 'user' or 'panel', got {actor_source!r}"
|
||||
)
|
||||
if actor_source == "user":
|
||||
if not isinstance(actor_user_id, str) or not actor_user_id.strip():
|
||||
raise ValueError("actor_source 'user' requires actor_user_id")
|
||||
if actor_child_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'user' must not carry actor_child_id"
|
||||
)
|
||||
return "user", actor_user_id.strip(), None
|
||||
if actor_user_id is not None:
|
||||
raise ValueError("actor_source 'panel' must not carry actor_user_id")
|
||||
if actor_child_id is None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' requires actor_child_id"
|
||||
)
|
||||
return (
|
||||
"panel",
|
||||
None,
|
||||
_validate_int_id(actor_child_id, "actor_child_id"),
|
||||
)
|
||||
|
||||
|
||||
def _validate_was_on_time(
|
||||
event_type: str, was_on_time: object
|
||||
) -> bool | None:
|
||||
"""Return a real bool or None; completed events require True/False."""
|
||||
if was_on_time is not None and type(was_on_time) is not bool:
|
||||
raise ValueError(
|
||||
f"was_on_time must be True, False or None, got {was_on_time!r}"
|
||||
)
|
||||
if event_type == EVENT_COMPLETED and was_on_time is None:
|
||||
raise ValueError(
|
||||
"a completed event requires was_on_time (True/False)"
|
||||
)
|
||||
return was_on_time
|
||||
|
||||
|
||||
async def append_event(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
child_id: int,
|
||||
event_type: str,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
was_on_time: bool | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
) -> CompletionEventRecord:
|
||||
"""Append one completion or un-completion event and return the row.
|
||||
|
||||
Validates actor shapes and the completed-event on-time requirement
|
||||
before calling :meth:`CompletionEventsDao.append`. ``occurred_at``
|
||||
is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
child_id = _validate_int_id(child_id, "child_id")
|
||||
event_type = _validate_event_type(event_type)
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
was_on_time = _validate_was_on_time(event_type, was_on_time)
|
||||
occurred_at = _stamp_occurred_at(now)
|
||||
return await CompletionEventsDao(database).append(
|
||||
instance_id,
|
||||
child_id,
|
||||
event_type,
|
||||
actor_source,
|
||||
occurred_at,
|
||||
was_on_time,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
)
|
||||
|
||||
|
||||
async def instance_state(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> str:
|
||||
"""Return ``open``, ``done``, or ``missed`` from the latest event.
|
||||
|
||||
No events, or a latest event of uncompleted, is ``open`` unless
|
||||
``due_date`` is before ``today`` (HA-local, threaded), in which
|
||||
case it is ``missed``. A latest event of completed is ``done``
|
||||
even when past due. ``missed`` is never stored as an event_type.
|
||||
Unknown ``instance_id`` raises ValueError naming the field.
|
||||
Existence and the latest event are read inside the connection lock
|
||||
so the answer is one snapshot.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
today_date = _resolve_today(today)
|
||||
async with _connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
return _derived_state(instance, latest, today_date)
|
||||
|
||||
|
||||
class CompletionResult(str):
|
||||
"""The derived instance state, carrying the append verdict.
|
||||
|
||||
A ``str`` subclass so every existing ``result == "done"`` contract
|
||||
keeps working; ``appended`` reports whether THIS call actually
|
||||
appended an event (decided under the same lock as the append), so
|
||||
a caller — the Feature 09 service path firing bus events —
|
||||
announces a transition exactly once even under a concurrent
|
||||
duplicate call where the loser's write is a no-op. A completion
|
||||
also carries the ``was_on_time`` verdict computed under that same
|
||||
lock, so the event payload never re-reads mutable latest state a
|
||||
racing reversal could have replaced.
|
||||
"""
|
||||
|
||||
appended: bool
|
||||
was_on_time: bool | None
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
state: str,
|
||||
*,
|
||||
appended: bool,
|
||||
was_on_time: bool | None = None,
|
||||
) -> "CompletionResult":
|
||||
obj = super().__new__(cls, state)
|
||||
obj.appended = appended
|
||||
obj.was_on_time = was_on_time
|
||||
return obj
|
||||
|
||||
|
||||
async def complete_instance(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> CompletionResult:
|
||||
"""Append a completed event unless the instance is already done.
|
||||
|
||||
Returns the derived state ``done``; ``appended`` reports whether
|
||||
THIS call appended (False on an already-done no-op). Unknown
|
||||
``instance_id`` raises ValueError naming the field. Existence,
|
||||
latest-event, and the append run under one connection lock so a
|
||||
concurrent complete cannot double-append.
|
||||
|
||||
``was_on_time`` is computed from the HA-local completion moment
|
||||
(threaded ``now`` / ``today``) against the instance ``due_date``
|
||||
and optional ``due_time``. ``now`` is the timezone-aware HA-local
|
||||
datetime; its wall clock is never converted to UTC. Same-day
|
||||
``due_time`` comparison requires ``now``.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
async with _reentrant_connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
if latest is not None and latest.event_type == EVENT_COMPLETED:
|
||||
return CompletionResult("done", appended=False)
|
||||
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
# Computed under the completion lock and carried on the result:
|
||||
# the bus event must report THIS completion's timing, never a
|
||||
# re-read of the latest event a racing reversal could replace.
|
||||
on_time = _was_on_time(instance, now, today)
|
||||
await append_event(
|
||||
database,
|
||||
instance.id,
|
||||
instance.child_id,
|
||||
EVENT_COMPLETED,
|
||||
actor_source=actor_source,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
was_on_time=on_time,
|
||||
now=now,
|
||||
)
|
||||
return CompletionResult("done", appended=True, was_on_time=on_time)
|
||||
|
||||
|
||||
async def uncomplete_instance(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> CompletionResult:
|
||||
"""Append an uncompleted event unless the instance is already open.
|
||||
|
||||
Returns the derived state ``open``, or ``missed`` when the instance
|
||||
is past due (``due_date`` before HA-local ``today``); ``appended``
|
||||
reports whether THIS call appended (False on an already-open
|
||||
no-op). Unknown ``instance_id`` raises ValueError naming the
|
||||
field. Existence, latest-event, and the append run under one
|
||||
connection lock so a concurrent uncomplete cannot double-append.
|
||||
The original completed row is not modified.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
today_date = _resolve_today(today)
|
||||
async with _reentrant_connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||
return CompletionResult(
|
||||
_derived_state(instance, latest, today_date),
|
||||
appended=False,
|
||||
)
|
||||
if latest.event_type != EVENT_COMPLETED:
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
latest = await append_event(
|
||||
database,
|
||||
instance.id,
|
||||
instance.child_id,
|
||||
EVENT_UNCOMPLETED,
|
||||
actor_source=actor_source,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
was_on_time=None,
|
||||
now=now,
|
||||
)
|
||||
return CompletionResult(
|
||||
_derived_state(instance, latest, today_date), appended=True
|
||||
)
|
||||
|
||||
|
||||
async def list_missed_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's missed instances due in the closed date range.
|
||||
|
||||
Missed means the latest event state is open and ``due_date`` is
|
||||
before ``today`` (HA-local, threaded). Uses
|
||||
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
|
||||
Never appends a missed event. Instances and latest events are
|
||||
read inside the connection lock so the list is one snapshot.
|
||||
"""
|
||||
child_id = _validate_int_id(child_id, "child_id")
|
||||
today_date = _resolve_today(today)
|
||||
async with _connection_lock(database):
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, range_start, range_end
|
||||
)
|
||||
events = CompletionEventsDao(database)
|
||||
missed: list[QuestInstanceRecord] = []
|
||||
for instance in instances:
|
||||
latest = await events.get_latest_for_instance(instance.id)
|
||||
if _derived_state(instance, latest, today_date) == "missed":
|
||||
missed.append(instance)
|
||||
return missed
|
||||
@@ -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 feature guardrail: callers get typed dataclasses back and never see
|
||||
raw rows or SQL. Every method is async and runs through
|
||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
||||
executor and the event loop never blocks.
|
||||
|
||||
Error contract: violations surface as the underlying
|
||||
:class:`sqlite3.IntegrityError` (duplicate, foreign-key, or CHECK
|
||||
failure) from the execute call; the DAO adds no wrapping exception
|
||||
type. A get that matches nothing returns ``None``; a mutation that
|
||||
matches nothing is reported by :class:`~.db.ExecutionResult.rowcount`,
|
||||
which each method returns where the caller may need it (reorder, and
|
||||
the admin remove/set paths).
|
||||
The children/admin DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_children` (extracted in
|
||||
task d0b691d8); this module re-exports it so existing
|
||||
``from .dao_children import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChildRecord:
|
||||
"""One row of ``children``."""
|
||||
|
||||
id: int
|
||||
display_name: str
|
||||
colour: str | None
|
||||
avatar_ref: str | None
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdminUserRecord:
|
||||
"""One row of ``admin_users``."""
|
||||
|
||||
ha_user_id: str
|
||||
added_at: str
|
||||
|
||||
|
||||
def _child_from_row(row: tuple) -> ChildRecord:
|
||||
"""Build a :class:`ChildRecord` from a SELECT row."""
|
||||
return ChildRecord(
|
||||
id=row[0],
|
||||
display_name=row[1],
|
||||
colour=row[2],
|
||||
avatar_ref=row[3],
|
||||
sort_order=row[4],
|
||||
is_active=bool(row[5]),
|
||||
created_at=row[6],
|
||||
)
|
||||
|
||||
|
||||
_CHILD_COLUMNS = (
|
||||
"id, display_name, colour, avatar_ref, sort_order, is_active, created_at"
|
||||
)
|
||||
|
||||
|
||||
#: One asyncio.Lock per (connection wrapper, running loop), shared by
|
||||
#: every ChildrenDao instance over the same database: the wrapper
|
||||
#: rejects a second concurrent transaction with RuntimeError, so ALL
|
||||
#: transactional DAO operations on one connection must queue here, not
|
||||
#: per-instance. Keyed like the migration runner's lock; entries are
|
||||
#: never evicted (a config entry holds one wrapper per process).
|
||||
_CONNECTION_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _connection_lock(database) -> asyncio.Lock:
|
||||
"""Return the shared transaction-serializing lock for ``database``."""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _CONNECTION_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_CONNECTION_LOCKS[key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
class ChildrenDao:
|
||||
"""Typed async data access for the ``children`` table.
|
||||
|
||||
Deletion is deliberately absent: children are deactivated, never
|
||||
deleted, because completion history references them (feature
|
||||
guardrail).
|
||||
"""
|
||||
|
||||
def __init__(self, database) -> None:
|
||||
self._database = database
|
||||
|
||||
async def create(
|
||||
self,
|
||||
display_name: str,
|
||||
created_at: str,
|
||||
*,
|
||||
colour: str | None = None,
|
||||
avatar_ref: str | None = None,
|
||||
sort_order: int = 0,
|
||||
is_active: bool = True,
|
||||
) -> ChildRecord:
|
||||
"""Insert one child and return the record as stored."""
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO children (display_name, colour, avatar_ref, "
|
||||
"sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
display_name,
|
||||
colour,
|
||||
avatar_ref,
|
||||
sort_order,
|
||||
int(is_active),
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
child = await self.get(result.lastrowid)
|
||||
assert child is not None
|
||||
return child
|
||||
|
||||
async def get(self, child_id: int) -> ChildRecord | None:
|
||||
"""Return the child with ``child_id``, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_CHILD_COLUMNS} FROM children WHERE id = ?",
|
||||
(child_id,),
|
||||
)
|
||||
return _child_from_row(row) if row is not None else None
|
||||
|
||||
async def list_active(self) -> list[ChildRecord]:
|
||||
"""Return active children ordered by sort_order, then id."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_CHILD_COLUMNS} FROM children WHERE is_active = 1 "
|
||||
"ORDER BY sort_order, id"
|
||||
)
|
||||
return [_child_from_row(row) for row in rows]
|
||||
|
||||
async def list_all(self) -> list[ChildRecord]:
|
||||
"""Return all children ordered by sort_order, then id."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_CHILD_COLUMNS} FROM children "
|
||||
"ORDER BY sort_order, id"
|
||||
)
|
||||
return [record for record in map(_child_from_row, rows)]
|
||||
|
||||
async def update(
|
||||
self,
|
||||
child_id: int,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
colour: str | None = None,
|
||||
avatar_ref: str | None = None,
|
||||
sort_order: int | None = None,
|
||||
) -> int:
|
||||
"""Update the given fields; returns rows updated (0 if absent).
|
||||
|
||||
Only non-None arguments are written, so a caller cannot
|
||||
accidentally blank a column by omitting it.
|
||||
"""
|
||||
assignments: list[str] = []
|
||||
parameters: list[object] = []
|
||||
if display_name is not None:
|
||||
assignments.append("display_name = ?")
|
||||
parameters.append(display_name)
|
||||
if colour is not None:
|
||||
assignments.append("colour = ?")
|
||||
parameters.append(colour)
|
||||
if avatar_ref is not None:
|
||||
assignments.append("avatar_ref = ?")
|
||||
parameters.append(avatar_ref)
|
||||
if sort_order is not None:
|
||||
assignments.append("sort_order = ?")
|
||||
parameters.append(sort_order)
|
||||
if not assignments:
|
||||
return 0
|
||||
parameters.append(child_id)
|
||||
result = await self._database.execute(
|
||||
f"UPDATE children SET {', '.join(assignments)} WHERE id = ?",
|
||||
tuple(parameters),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def set_active(self, child_id: int, is_active: bool) -> int:
|
||||
"""Set the child's active flag; returns rows updated (0 if absent)."""
|
||||
result = await self._database.execute(
|
||||
"UPDATE children SET is_active = ? WHERE id = ?",
|
||||
(int(is_active), child_id),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def reorder(self, ordered_ids: list[int]) -> None:
|
||||
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
|
||||
|
||||
The list must be a COMPLETE permutation of the children table:
|
||||
every existing child id appears exactly once. Partial lists
|
||||
(missing an existing child) and unknown ids (naming a child
|
||||
that does not exist) are rejected with ValueError BEFORE any
|
||||
write, leaving the current order untouched — a silent partial
|
||||
reorder would leave unlisted children with stale positions and
|
||||
hide a caller bug. Duplicate ids are rejected up front: they
|
||||
cannot all hold distinct positions, and silently assigning the
|
||||
last occurrence's index would hide a caller bug.
|
||||
|
||||
Runs inside one transaction; positions are staged at offset
|
||||
-len (all negative, so any permutation of the same ids never
|
||||
collides mid-reorder under the wrapper's single-connection
|
||||
serialization), then finalized 0..n-1.
|
||||
|
||||
Concurrent reorder() calls on one connection are serialized by
|
||||
a connection-scoped asyncio lock (shared across every ChildrenDao
|
||||
over the same wrapper, however many instances): the wrapper's
|
||||
transaction() rejects a second concurrent transaction with
|
||||
RuntimeError, so racing callers would crash instead of
|
||||
queueing. The lock makes the second call wait, then run
|
||||
against the first call's final state — which also keeps the
|
||||
completeness check honest: the re-read of the table happens
|
||||
inside the same lock and transaction as the writes.
|
||||
"""
|
||||
if len(set(ordered_ids)) != len(ordered_ids):
|
||||
raise ValueError(
|
||||
"reorder() received duplicate child ids; every id must "
|
||||
"appear at most once"
|
||||
)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT id FROM children"
|
||||
)
|
||||
existing = {row[0] for row in rows}
|
||||
listed = set(ordered_ids)
|
||||
missing = sorted(existing - listed)
|
||||
unknown = sorted(listed - existing)
|
||||
if missing or unknown:
|
||||
details = []
|
||||
if missing:
|
||||
details.append(
|
||||
f"missing child ids {missing} (a partial "
|
||||
"reorder would leave them with stale "
|
||||
"positions)"
|
||||
)
|
||||
if unknown:
|
||||
details.append(
|
||||
f"unknown child ids {unknown}"
|
||||
)
|
||||
raise ValueError(
|
||||
"reorder() must list every child exactly once; "
|
||||
+ "; ".join(details)
|
||||
)
|
||||
offset = len(ordered_ids)
|
||||
await self._database.execute_many(
|
||||
"UPDATE children SET sort_order = ? WHERE id = ?",
|
||||
[
|
||||
(-(offset + index), child_id)
|
||||
for index, child_id in enumerate(ordered_ids)
|
||||
],
|
||||
)
|
||||
await self._database.execute_many(
|
||||
"UPDATE children SET sort_order = ? WHERE id = ?",
|
||||
[
|
||||
(index, child_id)
|
||||
for index, child_id in enumerate(ordered_ids)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class AdminUsersDao:
|
||||
"""Typed async access to the ``admin_users`` allowlist.
|
||||
|
||||
The allowlist stores HA user IDs, never usernames (feature
|
||||
guardrail); an empty allowlist means no admin, enforced by callers
|
||||
resolving through :meth:`exists`.
|
||||
"""
|
||||
|
||||
def __init__(self, database) -> None:
|
||||
self._database = database
|
||||
|
||||
async def add(self, ha_user_id: str, added_at: str) -> bool:
|
||||
"""Add a user to the allowlist; True if inserted, False if present.
|
||||
|
||||
Idempotent under concurrency: a single INSERT ... ON CONFLICT DO
|
||||
NOTHING decides and inserts in one statement (the wrapper
|
||||
serializes statements, not check-then-insert sequences, so a
|
||||
SELECT/INSERT pair would let two concurrent adds both see no
|
||||
row and the second one raise IntegrityError). rowcount reports
|
||||
whether the insert actually happened.
|
||||
"""
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?) "
|
||||
"ON CONFLICT (ha_user_id) DO NOTHING",
|
||||
(ha_user_id, added_at),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def remove(self, ha_user_id: str) -> bool:
|
||||
"""Remove a user from the allowlist; True if a row was removed."""
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM admin_users WHERE ha_user_id = ?",
|
||||
(ha_user_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def list(self) -> list[AdminUserRecord]:
|
||||
"""Return every allowlisted user, oldest first."""
|
||||
rows = await self._database.fetch_all(
|
||||
"SELECT ha_user_id, added_at FROM admin_users "
|
||||
"ORDER BY added_at, ha_user_id"
|
||||
)
|
||||
return [
|
||||
AdminUserRecord(ha_user_id=row[0], added_at=row[1])
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def exists(self, ha_user_id: str) -> bool:
|
||||
"""Return True if ``ha_user_id`` is allowlisted."""
|
||||
row = await self._database.fetch_one(
|
||||
"SELECT 1 FROM admin_users WHERE ha_user_id = ?",
|
||||
(ha_user_id,),
|
||||
)
|
||||
return row is not None
|
||||
from .core.dao_children import * # noqa: F401,F403
|
||||
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
|
||||
|
||||
@@ -1,832 +1,12 @@
|
||||
"""Typed DAO layer for quest_instances and the append-only completion_events.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package.
|
||||
|
||||
All SQL for these tables lives in this module per the feature
|
||||
guardrails: callers get typed dataclasses back and never see raw rows
|
||||
or SQL. Every method is async and runs through
|
||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
||||
executor and the event loop never blocks.
|
||||
|
||||
Append-only policy (Feature 08, D-005): ``completion_events`` is
|
||||
strictly append-only. This module is where that property is enforced
|
||||
mechanically — the completion-events DAO below exposes NO update or
|
||||
delete method at all; completions append, un-completions append a
|
||||
separate reversal event, and instance state derives from the latest
|
||||
event. A package-wide guard test (bottom of the test module) keeps
|
||||
mutation SQL for this table out of every other module.
|
||||
|
||||
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
|
||||
(definition_id, child_id, due_date, window) — re-running
|
||||
materialization for a tuple whose instance already exists refreshes
|
||||
only the generation-time snapshot columns and never duplicates a row.
|
||||
The schema's UNIQUE(definition_id, child_id, due_date, window) is the
|
||||
last line of defense. The upsert refuses a child that is not an
|
||||
assignee of the definition and a window outside ``const.QUEST_WINDOWS``
|
||||
(D-008 multi-assignee, multi-window model).
|
||||
|
||||
``delete_future_uncompleted`` implements the Feature 06/07 rule that
|
||||
reassignment and schedule edits regenerate future instances: it
|
||||
removes only instances at or after the cutoff that have NO completion
|
||||
event, inside one transaction, so an instance completed mid-call can
|
||||
never be deleted. Instances in the past are never touched (Feature 07
|
||||
guardrail: never delete history), and no method here deletes a single
|
||||
instance that has a completion event.
|
||||
The instances/completion-events DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_instances`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .dao_instances import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .dao_rules import _validate_date, _validate_window
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
|
||||
#: One accepted shape bounds lexicographic range queries correctly, so
|
||||
#: the occurred-at scope does not guess at timestamp spellings.
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _today() -> datetime.date:
|
||||
"""Return the current local calendar date (the single date source).
|
||||
|
||||
Every "today" read in this module goes through this one function so
|
||||
the delete clamp and the materialize walk's no-past guard read the
|
||||
same clock, and a caller or test can pin/roll that clock
|
||||
deterministically (e.g. a queued-across-midnight regression). Pure
|
||||
clock read — no business rule lives here.
|
||||
"""
|
||||
return datetime.date.today()
|
||||
|
||||
|
||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
"""Return the caller-resolved "today", or the host date when absent.
|
||||
|
||||
The no-past guard and the delete cutoffs must compare due dates
|
||||
against the SAME calendar date the materialization horizon was built
|
||||
from. Home Assistant's configured time zone can lag the host clock
|
||||
around midnight, so when the caller has already resolved the local
|
||||
date (``today``) that anchor is authoritative; ``None`` falls back to
|
||||
the host clock (:func:`_today`), which is exactly the previous
|
||||
behavior for callers with no HA-local date.
|
||||
"""
|
||||
return _today() if today is None else today
|
||||
|
||||
|
||||
def _reject_past(due_date: str, today_iso: str) -> None:
|
||||
"""Raise unless ``due_date`` is on or after ``today_iso``.
|
||||
|
||||
The single no-past rule shared by :meth:`QuestInstancesDao.upsert`
|
||||
and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never
|
||||
generated in the past, measured against the resolved "today".
|
||||
"""
|
||||
if due_date < today_iso:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: due_date "
|
||||
f"{due_date!r} is before today {today_iso!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_utc_timestamp(value: str, field: str) -> None:
|
||||
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
|
||||
|
||||
Same strictness policy as _validate_date: parse with the exact
|
||||
format, then round-trip the parsed value against the input so
|
||||
non-padded or re-ordered shapes are rejected. The round-trip
|
||||
strips the UTC offset (isoformat() omits it when the offset is
|
||||
zero), so the suffix is appended before comparing.
|
||||
"""
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(
|
||||
value, _UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
f"{field} must be a UTC ISO-8601 timestamp "
|
||||
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() + "+00:00" != value:
|
||||
raise ValueError(
|
||||
f"{field} must be a strict UTC timestamp "
|
||||
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _next_day_start(date_str: str) -> str:
|
||||
"""Return the UTC timestamp one second past ``date_str``'s last second.
|
||||
|
||||
Gives range queries an exact half-open upper bound without
|
||||
fractional-second guessing.
|
||||
"""
|
||||
day = datetime.date.fromisoformat(date_str)
|
||||
next_day = day + datetime.timedelta(days=1)
|
||||
return f"{next_day.isoformat()}T00:00:00+00:00"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestInstanceRecord:
|
||||
"""One row of ``quest_instances``."""
|
||||
|
||||
id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
window: str
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
generated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionEventRecord:
|
||||
"""One row of ``completion_events`` (append-only table)."""
|
||||
|
||||
id: int
|
||||
instance_id: int
|
||||
child_id: int
|
||||
event_type: str
|
||||
actor_source: str
|
||||
actor_user_id: str | None
|
||||
actor_child_id: int | None
|
||||
occurred_at: str
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
_INSTANCE_COLUMNS = (
|
||||
"id, definition_id, child_id, window, due_date, due_time, generated_at"
|
||||
)
|
||||
_EVENT_COLUMNS = (
|
||||
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
|
||||
"actor_child_id, occurred_at, was_on_time"
|
||||
)
|
||||
|
||||
#: The only event types the append path accepts; the schema CHECK is
|
||||
#: the backstop, this is the DAO's typed front door.
|
||||
EVENT_COMPLETED = "completed"
|
||||
EVENT_UNCOMPLETED = "uncompleted"
|
||||
|
||||
|
||||
def _instance_from_row(row: tuple) -> QuestInstanceRecord:
|
||||
return QuestInstanceRecord(
|
||||
id=row[0],
|
||||
definition_id=row[1],
|
||||
child_id=row[2],
|
||||
window=row[3],
|
||||
due_date=row[4],
|
||||
due_time=row[5],
|
||||
generated_at=row[6],
|
||||
)
|
||||
|
||||
|
||||
def _event_from_row(row: tuple) -> CompletionEventRecord:
|
||||
return CompletionEventRecord(
|
||||
id=row[0],
|
||||
instance_id=row[1],
|
||||
child_id=row[2],
|
||||
event_type=row[3],
|
||||
actor_source=row[4],
|
||||
actor_user_id=row[5],
|
||||
actor_child_id=row[6],
|
||||
occurred_at=row[7],
|
||||
was_on_time=None if row[8] is None else bool(row[8]),
|
||||
)
|
||||
|
||||
|
||||
class QuestInstancesDao:
|
||||
"""Typed async access to the ``quest_instances`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
due_date: str,
|
||||
generated_at: str,
|
||||
*,
|
||||
window: str,
|
||||
due_time: str | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> QuestInstanceRecord:
|
||||
"""Create the dated instance for the widened key idempotently.
|
||||
|
||||
The instance key is (definition_id, child_id, due_date, window)
|
||||
(D-008): running materialization twice for the same tuple
|
||||
yields ONE row — the ON CONFLICT path refreshes the
|
||||
generation-time snapshot columns (due_time) and regenerates the
|
||||
stamp, but can never create a duplicate. The schema's
|
||||
UNIQUE(definition_id, child_id, due_date, window) backs this
|
||||
up, so the twice-daily case (two windows, one child, one date)
|
||||
yields two rows and the shared case (three assignees, one
|
||||
window) yields three.
|
||||
|
||||
Guardrails enforced here:
|
||||
|
||||
- ``due_date`` must be TODAY or later: instances are never
|
||||
generated in the past (Feature 07). The caller's
|
||||
materialization horizon is future-dated by definition.
|
||||
``today`` optionally pins the resolved HA-local date the
|
||||
guard compares against (falling back to the host clock when
|
||||
omitted) so a household time zone behind the host around
|
||||
midnight is not misread as "past".
|
||||
- ``window`` must be one of ``const.QUEST_WINDOWS``.
|
||||
- An existing instance WITH a completion event is IMMUTABLE:
|
||||
the conflict path must not rewrite its snapshot columns
|
||||
(Feature 07/08). The upsert refuses with ValueError in that
|
||||
case — silently no-oping would mask a materialization bug.
|
||||
- The child must be an assignee of the definition (D-008
|
||||
multi-assignee model).
|
||||
|
||||
The definition must exist (validated atomically under the
|
||||
connection lock so a concurrent definition deactivation or
|
||||
child deletion cannot slip between check and insert).
|
||||
"""
|
||||
_validate_date(due_date, "due_date")
|
||||
_validate_window(window)
|
||||
# Fail fast on obviously-past dates BEFORE queuing on the
|
||||
# connection lock (a nice early error); the AUTHORITATIVE
|
||||
# check runs after the lock is acquired, because a caller can
|
||||
# wait on the lock across midnight — a date that was "today"
|
||||
# at call time may be "yesterday" by the time the INSERT runs.
|
||||
# A caller-supplied ``today`` (HA-local) is used for BOTH
|
||||
# checks and never re-read, since it is already the anchor the
|
||||
# batch runs against.
|
||||
today_date = _resolve_today(today)
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# Re-read "today" under the lock: this is the date the
|
||||
# insert actually executes on, so the no-past rule
|
||||
# holds across midnight rollovers. A pinned HA-local
|
||||
# anchor is exempt from the re-read (see above).
|
||||
if today is None:
|
||||
today_date = _today()
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
definition = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definitions WHERE id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
if definition is None:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} does not exist"
|
||||
)
|
||||
assignee = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ? AND child_id = ?",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
if assignee is None:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} is not "
|
||||
f"assigned to child {child_id}; instance must "
|
||||
"carry an assigned child"
|
||||
)
|
||||
existing = await self._database.fetch_one(
|
||||
"SELECT 1 FROM completion_events "
|
||||
"WHERE instance_id = (SELECT id FROM quest_instances "
|
||||
"WHERE definition_id = ? AND child_id = ? "
|
||||
"AND due_date = ? AND window = ?)",
|
||||
(definition_id, child_id, due_date, window),
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError(
|
||||
f"instance for (definition {definition_id}, "
|
||||
f"child {child_id}, due_date {due_date!r}, "
|
||||
f"window {window!r}) already has a completion "
|
||||
"event and is immutable; it cannot be regenerated"
|
||||
)
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||
"window, due_date, due_time, generated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (definition_id, child_id, due_date, "
|
||||
"window) DO UPDATE SET "
|
||||
"due_time = excluded.due_time, "
|
||||
"generated_at = excluded.generated_at",
|
||||
(
|
||||
definition_id,
|
||||
child_id,
|
||||
window,
|
||||
due_date,
|
||||
due_time,
|
||||
generated_at,
|
||||
),
|
||||
)
|
||||
instance = await self.get(
|
||||
definition_id, child_id, due_date, window
|
||||
)
|
||||
assert instance is not None
|
||||
return instance
|
||||
|
||||
async def upsert_if_valid(
|
||||
self,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
due_date: str,
|
||||
generated_at: str,
|
||||
*,
|
||||
window: str,
|
||||
due_time: str | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> QuestInstanceRecord | None:
|
||||
"""Insert one dated instance iff its tuple is still materializable.
|
||||
|
||||
This is the materialization walk's single write path. Unlike the
|
||||
generic :meth:`upsert`, which validates only existence and
|
||||
assignment, this method re-validates EVERY materialization
|
||||
precondition in the SAME connection-lock-held transaction as the
|
||||
INSERT — closing the check-then-insert gap a concurrent config
|
||||
write could otherwise slip through:
|
||||
|
||||
- the definition exists AND is active (a definition deactivated
|
||||
after the input snapshot is skipped, never written);
|
||||
- the child exists AND is active (a deactivated child is skipped);
|
||||
- the (definition, child) assignment link still exists (a removed
|
||||
assignment is skipped);
|
||||
- the window is still declared on the definition (a removed window
|
||||
is skipped);
|
||||
- the window's live ``due_time`` still EQUALS the caller-supplied
|
||||
``due_time`` (NULL-safe: NULL == NULL counts as equal; a window
|
||||
with no due time matches a snapshot with no due time). A
|
||||
window-time edit landed after the input snapshot makes the
|
||||
caller's ``due_time`` stale, and writing it would overwrite the
|
||||
newer due_time a concurrent regeneration already upserted — so
|
||||
the tuple is skipped instead of written.
|
||||
|
||||
The due_time check closes the one check-then-insert gap this
|
||||
method previously left open: it re-validated every OTHER
|
||||
precondition but then wrote the snapshot's ``due_time``
|
||||
unconditionally, so a stale materialization could clobber a
|
||||
newer, concurrently-regenerated due_time.
|
||||
|
||||
Returns the stored instance, or ``None`` when a precondition
|
||||
failed — the tuple no longer materializable, which the walk SKIPS
|
||||
rather than raising, so the rest of the batch is unaffected. The
|
||||
no-past rule (instances are never generated in the past) is
|
||||
preserved from :meth:`upsert` and still raises ValueError; ``today``
|
||||
optionally pins the HA-local anchor it compares against. An
|
||||
instance that ALREADY has a completion event is also skipped
|
||||
(returns ``None``) rather than raised: it is immutable, so the
|
||||
walk leaves it untouched instead of failing the batch — the
|
||||
generic :meth:`upsert` keeps its hard refusal for the
|
||||
delete/regenerate path.
|
||||
"""
|
||||
_validate_date(due_date, "due_date")
|
||||
_validate_window(window)
|
||||
# Fail fast on obviously-past dates before queuing on the lock;
|
||||
# the authoritative check re-runs under the lock (see upsert).
|
||||
today_date = _resolve_today(today)
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
if today is None:
|
||||
today_date = _today()
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
still_valid = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definitions d "
|
||||
"JOIN quest_definition_assignees a "
|
||||
"ON a.definition_id = d.id "
|
||||
"JOIN children c ON c.id = a.child_id "
|
||||
"JOIN quest_definition_windows w "
|
||||
"ON w.definition_id = d.id "
|
||||
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
|
||||
"AND d.is_active = 1 AND c.is_active = 1 "
|
||||
"AND w.due_time IS ? LIMIT 1",
|
||||
(definition_id, child_id, window, due_time),
|
||||
)
|
||||
if still_valid is None:
|
||||
# A config change invalidated the tuple since the
|
||||
# snapshot: skip it rather than abort the batch.
|
||||
return None
|
||||
existing = await self._database.fetch_one(
|
||||
"SELECT 1 FROM completion_events "
|
||||
"WHERE instance_id = (SELECT id FROM quest_instances "
|
||||
"WHERE definition_id = ? AND child_id = ? "
|
||||
"AND due_date = ? AND window = ?)",
|
||||
(definition_id, child_id, due_date, window),
|
||||
)
|
||||
if existing is not None:
|
||||
# The instance is already completed and therefore
|
||||
# immutable: the walk SKIPS it (no-op) rather than
|
||||
# rewriting its snapshot columns or failing the
|
||||
# batch. This is the materialization walk's skip,
|
||||
# distinct from the generic :meth:`upsert` refusal.
|
||||
return None
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||
"window, due_date, due_time, generated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (definition_id, child_id, due_date, "
|
||||
"window) DO UPDATE SET "
|
||||
"due_time = excluded.due_time, "
|
||||
"generated_at = excluded.generated_at",
|
||||
(
|
||||
definition_id,
|
||||
child_id,
|
||||
window,
|
||||
due_date,
|
||||
due_time,
|
||||
generated_at,
|
||||
),
|
||||
)
|
||||
instance = await self.get(
|
||||
definition_id, child_id, due_date, window
|
||||
)
|
||||
assert instance is not None
|
||||
return instance
|
||||
|
||||
async def get(
|
||||
self,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
due_date: str,
|
||||
window: str,
|
||||
) -> QuestInstanceRecord | None:
|
||||
"""Return the instance for the widened key tuple, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE definition_id = ? AND child_id = ? AND due_date = ? "
|
||||
"AND window = ?",
|
||||
(definition_id, child_id, due_date, window),
|
||||
)
|
||||
return _instance_from_row(row) if row is not None else None
|
||||
|
||||
async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None:
|
||||
"""Return the instance with ``instance_id``, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?",
|
||||
(instance_id,),
|
||||
)
|
||||
return _instance_from_row(row) if row is not None else None
|
||||
|
||||
async def list_by_child_and_date(
|
||||
self, child_id: int, due_date: str
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's instances due exactly on ``due_date``."""
|
||||
_validate_date(due_date, "due_date")
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE child_id = ? AND due_date = ? ORDER BY id",
|
||||
(child_id, due_date),
|
||||
)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def list_by_date_range(
|
||||
self,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's instances with due_date in the closed range."""
|
||||
_validate_date(range_start, "range_start")
|
||||
_validate_date(range_end, "range_end")
|
||||
if range_end < range_start:
|
||||
raise ValueError(
|
||||
"range_end must be on or after range_start, got "
|
||||
f"{range_end!r} < {range_start!r}"
|
||||
)
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE child_id = ? AND due_date >= ? AND due_date <= ? "
|
||||
"ORDER BY due_date, id",
|
||||
(child_id, range_start, range_end),
|
||||
)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def list_missed_unswept(
|
||||
self, today_iso: str, watermark: str | None
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return past-due open instances the sweep has not announced.
|
||||
|
||||
Feature 11's missed-sweep query: ``due_date`` strictly before
|
||||
``today_iso`` (past due), at or after ``watermark`` when one is
|
||||
recorded, and with NO completion event ever (the Feature 08
|
||||
append-only contract: an instance that was ever completed is
|
||||
never reported missed, even when later reversed — history keeps
|
||||
both rows and the sweep never mutates any). The watermark is
|
||||
the HA-local date the sweep last ran, and the window is
|
||||
``[watermark, today)``: the nightly run entering day D+1 sweeps
|
||||
exactly day D's instances, so every day's quests are announced
|
||||
once — at the midnight after their due date — and a same-night
|
||||
rerun (watermark == today) sees an empty window. Read-only;
|
||||
ordered deterministically by (due_date, id).
|
||||
"""
|
||||
_validate_date(today_iso, "today_iso")
|
||||
if watermark is not None:
|
||||
_validate_date(watermark, "watermark")
|
||||
sql = (
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE due_date < ? "
|
||||
"AND NOT EXISTS ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = quest_instances.id"
|
||||
")"
|
||||
)
|
||||
parameters: tuple = (today_iso,)
|
||||
if watermark is not None:
|
||||
sql += " AND due_date >= ?"
|
||||
parameters += (watermark,)
|
||||
sql += " ORDER BY due_date, id"
|
||||
rows = await self._database.fetch_all(sql, parameters)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def delete_future_uncompleted(
|
||||
self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None
|
||||
) -> int:
|
||||
"""Delete the definition's open instances at/after cutoff.
|
||||
|
||||
Implements regeneration after a definition edit or reassignment
|
||||
(Features 06/07): only instances at or after the cutoff with NO
|
||||
completion event are removed, inside one transaction — an
|
||||
instance that gains a completion event mid-flight cannot be
|
||||
deleted. The cutoff cannot be backdated below today: "future"
|
||||
is measured against TODAY (materialization regenerates the
|
||||
rolling horizon from now), so a caller cannot use this to
|
||||
rewrite past open instances either — the day-rollover missed
|
||||
sweep (Feature 11) handles past-due instances instead. ``today``
|
||||
optionally pins the HA-local anchor the clamp compares against
|
||||
(falling back to the host clock when omitted). Any instance with
|
||||
a completion event is never touched, regardless of its date.
|
||||
Returns the number deleted.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# "today" is read under the lock: the DELETE actually
|
||||
# executes on this date, so a caller queued across
|
||||
# midnight cannot delete instances that became past
|
||||
# while it waited (backdating is clamped to the
|
||||
# execution date, not the call date).
|
||||
today_date = _resolve_today(today)
|
||||
effective_cutoff = max(cutoff_date, today_date.isoformat())
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM quest_instances WHERE definition_id = ? "
|
||||
"AND due_date >= ? AND NOT EXISTS ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = quest_instances.id"
|
||||
")",
|
||||
(definition_id, effective_cutoff),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def delete_future_uncompleted_for_child(
|
||||
self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None
|
||||
) -> tuple[int, str]:
|
||||
"""Delete the child's open instances at/after cutoff, across every
|
||||
definition, returning ``(deleted, effective_cutoff)``.
|
||||
|
||||
Child-scoped counterpart to :meth:`delete_future_uncompleted`,
|
||||
used when a child's presence changes (Feature 09): only instances
|
||||
at or after the cutoff with NO completion event are removed, inside
|
||||
one transaction — an instance that gains a completion event
|
||||
mid-flight cannot be deleted. The cutoff cannot be backdated below
|
||||
today, for the same reason as the definition-scoped method (see its
|
||||
docstring). ``today`` optionally pins the HA-local anchor the clamp
|
||||
compares against (falling back to the host clock when omitted).
|
||||
Any instance with a completion event is never touched, regardless
|
||||
of its date.
|
||||
|
||||
Returns the number deleted and the effective cutoff that was
|
||||
actually applied (``max(cutoff_date, today)``, read under the
|
||||
lock). The caller (:func:`~.materialize.regenerate_for_child`)
|
||||
passes that effective cutoff back as its re-materialization anchor,
|
||||
so the delete and the rebuild share ONE execution-day even when the
|
||||
call is queued across midnight — instead of the caller re-reading a
|
||||
stale "today" and materializing a window the delete already
|
||||
superseded.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# "today" is read under the lock, matching the
|
||||
# definition-scoped method: the DELETE executes on this
|
||||
# date, so a caller queued across midnight cannot delete
|
||||
# instances that became past while it waited.
|
||||
today_date = _resolve_today(today)
|
||||
effective_cutoff = max(cutoff_date, today_date.isoformat())
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM quest_instances WHERE child_id = ? "
|
||||
"AND due_date >= ? AND NOT EXISTS ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = quest_instances.id"
|
||||
")",
|
||||
(child_id, effective_cutoff),
|
||||
)
|
||||
return result.rowcount, effective_cutoff
|
||||
|
||||
|
||||
class CompletionEventsDao:
|
||||
"""Typed append-only access to the ``completion_events`` table.
|
||||
|
||||
APPEND-ONLY BY CONSTRUCTION: this class exposes ``append`` and the
|
||||
three read methods below, and NOTHING else. There is no update,
|
||||
no delete, no upsert — completions and un-completions both arrive
|
||||
through :meth:`append` as new rows, and history is immutable
|
||||
(Feature 08 done-condition: no code path updates or deletes an
|
||||
event). The package-wide leak-guard test additionally fails if
|
||||
mutation SQL for this table ever appears in any module.
|
||||
"""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def append(
|
||||
self,
|
||||
instance_id: int,
|
||||
child_id: int,
|
||||
event_type: str,
|
||||
actor_source: str,
|
||||
occurred_at: str,
|
||||
was_on_time: bool | None,
|
||||
*,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
) -> CompletionEventRecord:
|
||||
"""Append one completion or un-completion event.
|
||||
|
||||
``actor_source`` is 'user' (an admin action: ``actor_user_id``
|
||||
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
|
||||
id, and ``actor_child_id`` carrying the tapped child profile,
|
||||
D-008); the actor-pair CHECKs enforce this shape at the schema
|
||||
level. ``occurred_at`` must be the module's one strict UTC
|
||||
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
|
||||
queries stay exact. ``was_on_time`` is required (True/False)
|
||||
for completions; for un-completions it may be None or the
|
||||
reversed completion's flag (Feature 08 still deciding). The
|
||||
instance, child and — for panel events — the tapped child must
|
||||
exist — validated atomically under the connection lock.
|
||||
"""
|
||||
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {event_type!r}"
|
||||
)
|
||||
if actor_source not in ("user", "panel"):
|
||||
raise ValueError(
|
||||
f"actor_source must be 'user' or 'panel', got "
|
||||
f"{actor_source!r}"
|
||||
)
|
||||
if actor_source == "user" and not actor_user_id:
|
||||
raise ValueError(
|
||||
"actor_source 'user' requires actor_user_id"
|
||||
)
|
||||
if actor_source == "panel" and actor_user_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' must not carry actor_user_id"
|
||||
)
|
||||
if actor_source == "panel" and actor_child_id is None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' requires actor_child_id "
|
||||
"(the tapped child profile)"
|
||||
)
|
||||
if actor_source == "user" and actor_child_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'user' must not carry actor_child_id"
|
||||
)
|
||||
if was_on_time is not None and type(was_on_time) is not bool:
|
||||
raise ValueError(
|
||||
f"was_on_time must be True, False or None, got "
|
||||
f"{was_on_time!r}"
|
||||
)
|
||||
if event_type == EVENT_COMPLETED and was_on_time is None:
|
||||
raise ValueError(
|
||||
"a completed event requires was_on_time (True/False)"
|
||||
)
|
||||
_validate_utc_timestamp(occurred_at, "occurred_at")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
instance = await self._database.fetch_one(
|
||||
"SELECT child_id FROM quest_instances WHERE id = ?",
|
||||
(instance_id,),
|
||||
)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"quest instance {instance_id} does not exist"
|
||||
)
|
||||
if instance[0] != child_id:
|
||||
raise ValueError(
|
||||
f"quest instance {instance_id} belongs to child "
|
||||
f"{instance[0]}, not {child_id}"
|
||||
)
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
if actor_child_id is not None:
|
||||
tapped = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?",
|
||||
(actor_child_id,),
|
||||
)
|
||||
if tapped is None:
|
||||
raise ValueError(
|
||||
f"actor_child_id {actor_child_id} does not "
|
||||
"reference an existing child"
|
||||
)
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO completion_events (instance_id, child_id, "
|
||||
"event_type, actor_source, actor_user_id, "
|
||||
"actor_child_id, occurred_at, was_on_time) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
instance_id,
|
||||
child_id,
|
||||
event_type,
|
||||
actor_source,
|
||||
actor_user_id,
|
||||
actor_child_id,
|
||||
occurred_at,
|
||||
None if was_on_time is None else int(was_on_time),
|
||||
),
|
||||
)
|
||||
event = await self._database.fetch_one(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE id = ?",
|
||||
(result.lastrowid,),
|
||||
)
|
||||
assert event is not None
|
||||
return _event_from_row(event)
|
||||
|
||||
async def list_by_instance(
|
||||
self, instance_id: int
|
||||
) -> list[CompletionEventRecord]:
|
||||
"""Return every event for the instance, in append order."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE instance_id = ? ORDER BY id",
|
||||
(instance_id,),
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
|
||||
async def list_by_child_and_date_range(
|
||||
self,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
*,
|
||||
due_date_scope: bool = True,
|
||||
) -> list[CompletionEventRecord]:
|
||||
"""Return the child's events for instances due in the closed range.
|
||||
|
||||
Scopes by the INSTANCE's due date (the day the task was owed),
|
||||
not the event's wall-clock timestamp: history reporting asks
|
||||
"what happened to the tasks due that day" (Feature 13). Set
|
||||
``due_date_scope=False`` to filter by ``occurred_at`` date
|
||||
instead. Ordered by instance, then event id.
|
||||
"""
|
||||
_validate_date(range_start, "range_start")
|
||||
_validate_date(range_end, "range_end")
|
||||
if range_end < range_start:
|
||||
raise ValueError(
|
||||
"range_end must be on or after range_start, got "
|
||||
f"{range_end!r} < {range_start!r}"
|
||||
)
|
||||
if due_date_scope:
|
||||
predicate = (
|
||||
"SELECT id FROM quest_instances WHERE child_id = ? "
|
||||
"AND due_date >= ? AND due_date <= ?"
|
||||
)
|
||||
parameters: list[object] = [child_id, range_start, range_end]
|
||||
else:
|
||||
# Occurred-at scope: occurred_at is validated to the one
|
||||
# accepted UTC shape at append time, so half-open day
|
||||
# bounds in that same shape are exact (no spelling drift).
|
||||
occurred_start = f"{range_start}T00:00:00+00:00"
|
||||
occurred_end = f"{range_end}T23:59:59+00:00"
|
||||
query = (
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE child_id = ? AND occurred_at >= ? "
|
||||
"AND occurred_at < ? ORDER BY instance_id, id"
|
||||
)
|
||||
rows = await self._database.fetch_all(
|
||||
query,
|
||||
[
|
||||
child_id,
|
||||
occurred_start,
|
||||
_next_day_start(range_end),
|
||||
],
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
f"WHERE instance_id IN ({predicate}) "
|
||||
"ORDER BY instance_id, id",
|
||||
tuple(parameters),
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
|
||||
async def get_latest_for_instance(
|
||||
self, instance_id: int
|
||||
) -> CompletionEventRecord | None:
|
||||
"""Return the instance's most recent event, or None if untouched.
|
||||
|
||||
Instance current state derives from this event (Feature 08):
|
||||
'completed' means done, 'uncompleted' as the latest means open
|
||||
again, None means never completed.
|
||||
"""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE instance_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(instance_id,),
|
||||
)
|
||||
return _event_from_row(row) if row is not None else None
|
||||
from .core.dao_instances import * # noqa: F401,F403
|
||||
from .core.dao_instances import _resolve_today # noqa: F401
|
||||
|
||||
@@ -1,44 +1,11 @@
|
||||
"""Typed DAO for the integration-owned ``nestquest_meta_state`` table.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package.
|
||||
|
||||
Generic persistent key/value bookkeeping that belongs to the
|
||||
integration itself rather than any domain table — currently the
|
||||
Feature 11 missed-sweep watermark. Values are plain strings; callers
|
||||
interpret them. All SQL for this table lives here per the feature
|
||||
guardrails, and every method runs through
|
||||
:class:`~.db.NestQuestDatabase` so nothing blocks the event loop.
|
||||
The meta-state DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_meta` (extracted in task
|
||||
d0b691d8); this module re-exports it so existing
|
||||
``from .dao_meta import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
_TABLE = "nestquest_meta_state"
|
||||
|
||||
|
||||
class MetaStateDao:
|
||||
"""Typed async access to the ``nestquest_meta_state`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Return the stored value for ``key``, or None when absent."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT value FROM {_TABLE} WHERE key = ?", (key,)
|
||||
)
|
||||
return row[0] if row is not None else None
|
||||
|
||||
async def set(self, key: str, value: str) -> None:
|
||||
"""Upsert one key/value row atomically.
|
||||
|
||||
The write runs inside one transaction under the shared
|
||||
connection lock, so a racing reader either sees the old value
|
||||
or the fully-written new one, never a half-applied state.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
await self._database.execute(
|
||||
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
from .core.dao_meta import * # noqa: F401,F403
|
||||
|
||||
@@ -1,343 +1,11 @@
|
||||
"""Typed DAO layer for the presence_schedules and presence_overrides tables.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package.
|
||||
|
||||
All SQL for these tables lives in this module per the feature
|
||||
guardrails: callers get typed dataclasses back and never see raw rows
|
||||
or SQL. Every method is async and runs through
|
||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
||||
executor and the event loop never blocks.
|
||||
|
||||
Semantics per the done-condition and Feature 05:
|
||||
|
||||
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting
|
||||
by child updates the existing row rather than raising.
|
||||
- A child with NO schedule row is present every day — the absence of a
|
||||
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child`
|
||||
returning None, never by an empty pattern row.
|
||||
- Overrides beat the pattern for their date range (that resolution is
|
||||
the presence engine's job, Feature 05); the DAO only stores and
|
||||
lists them. A single-day override stores the same date as both
|
||||
start and end (schema CHECK requires end >= start).
|
||||
|
||||
Date arguments are validated as strict YYYY-MM-DD per the same policy
|
||||
the rules DAO applies: the schema's lexical CHECKs cannot prove
|
||||
calendar validity.
|
||||
The presence DAOs live in
|
||||
:mod:`custom_components.nestquest.core.dao_presence` (extracted in
|
||||
task d0b691d8); this module re-exports them so existing
|
||||
``from .dao_presence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .dao_rules import _UNSET, _validate_date
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PresenceScheduleRecord:
|
||||
"""One row of ``presence_schedules``."""
|
||||
|
||||
id: int
|
||||
child_id: int
|
||||
cycle_length_weeks: int
|
||||
anchor_date: str
|
||||
pattern: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PresenceOverrideRecord:
|
||||
"""One row of ``presence_overrides``."""
|
||||
|
||||
id: int
|
||||
child_id: int
|
||||
start_date: str
|
||||
end_date: str
|
||||
is_present: bool
|
||||
note: str | None
|
||||
|
||||
|
||||
_SCHEDULE_COLUMNS = (
|
||||
"id, child_id, cycle_length_weeks, anchor_date, pattern"
|
||||
)
|
||||
_OVERRIDE_COLUMNS = (
|
||||
"id, child_id, start_date, end_date, is_present, note"
|
||||
)
|
||||
|
||||
|
||||
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord:
|
||||
return PresenceScheduleRecord(
|
||||
id=row[0],
|
||||
child_id=row[1],
|
||||
cycle_length_weeks=row[2],
|
||||
anchor_date=row[3],
|
||||
pattern=row[4],
|
||||
)
|
||||
|
||||
|
||||
def _override_from_row(row: tuple) -> PresenceOverrideRecord:
|
||||
return PresenceOverrideRecord(
|
||||
id=row[0],
|
||||
child_id=row[1],
|
||||
start_date=row[2],
|
||||
end_date=row[3],
|
||||
is_present=bool(row[4]),
|
||||
note=row[5],
|
||||
)
|
||||
|
||||
|
||||
class PresenceSchedulesDao:
|
||||
"""Typed async access to the ``presence_schedules`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def upsert_by_child(
|
||||
self,
|
||||
child_id: int,
|
||||
cycle_length_weeks: int,
|
||||
anchor_date: str,
|
||||
pattern: str,
|
||||
) -> PresenceScheduleRecord:
|
||||
"""Set the child's presence schedule, replacing any existing one.
|
||||
|
||||
Upsert semantics are the done-condition: a second schedule for
|
||||
the same child updates the existing row rather than raising.
|
||||
Runs under the connection lock so a concurrent upsert/delete
|
||||
for the same child serializes instead of colliding on the
|
||||
schema's UNIQUE(child_id). Pattern validity (segment shape,
|
||||
segment count = cycle_length_weeks) is enforced by the schema
|
||||
CHECKs; the child must exist (FK) — validated atomically here
|
||||
so a deleted child cannot pass a stale check.
|
||||
"""
|
||||
_validate_date(anchor_date, "anchor_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
await self._database.execute(
|
||||
"INSERT INTO presence_schedules (child_id, "
|
||||
"cycle_length_weeks, anchor_date, pattern) "
|
||||
"VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT (child_id) DO UPDATE SET "
|
||||
"cycle_length_weeks = excluded.cycle_length_weeks, "
|
||||
"anchor_date = excluded.anchor_date, "
|
||||
"pattern = excluded.pattern",
|
||||
(child_id, cycle_length_weeks, anchor_date, pattern),
|
||||
)
|
||||
schedule = await self.get_by_child(child_id)
|
||||
assert schedule is not None
|
||||
return schedule
|
||||
|
||||
async def get_by_child(
|
||||
self, child_id: int
|
||||
) -> PresenceScheduleRecord | None:
|
||||
"""Return the child's schedule, or None when the child has none.
|
||||
|
||||
None is meaningful and UNAMBIGUOUS here: the child exists and
|
||||
has no schedule row, i.e. the child is present every day
|
||||
(Feature 05 guardrail). An unknown child id raises ValueError
|
||||
instead of returning None, because "nonexistent child" must
|
||||
never be silently read as "present every day".
|
||||
"""
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
|
||||
"WHERE child_id = ?",
|
||||
(child_id,),
|
||||
)
|
||||
return _schedule_from_row(row) if row is not None else None
|
||||
|
||||
async def delete(self, child_id: int) -> bool:
|
||||
"""Remove the child's schedule; True if a row was removed.
|
||||
|
||||
After deletion the child is present every day (no row means
|
||||
no restriction). Deleting a schedule the child never had
|
||||
returns False.
|
||||
"""
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM presence_schedules WHERE child_id = ?",
|
||||
(child_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
class PresenceOverridesDao:
|
||||
"""Typed async access to the ``presence_overrides`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def create(
|
||||
self,
|
||||
child_id: int,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
is_present: bool,
|
||||
*,
|
||||
note: str | None = None,
|
||||
) -> PresenceOverrideRecord:
|
||||
"""Insert one override and return the record as stored.
|
||||
|
||||
The child must exist (validated atomically under the
|
||||
connection lock). Date shape is validated strictly, and the
|
||||
schema CHECK rejects end < start.
|
||||
|
||||
Overlap policy (Feature 05): an override for a child may not
|
||||
overlap another override of the SAME child — two different
|
||||
present/absent answers for one date are unresolvable. The
|
||||
overlap check (inclusive boundaries) runs inside the same
|
||||
transaction as the INSERT, so a racing create cannot slip a
|
||||
conflicting override between the check and the write; a
|
||||
conflict raises ValueError naming the conflicting override's
|
||||
id and range.
|
||||
"""
|
||||
_validate_date(start_date, "start_date")
|
||||
_validate_date(end_date, "end_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
# Only a well-ordered range has overlap semantics:
|
||||
# inverted ranges fall through to the INSERT and hit
|
||||
# the schema's end>=start CHECK, preserving the
|
||||
# IntegrityError contract the matrix tests rely on.
|
||||
if end_date >= start_date:
|
||||
conflicting = await self._database.fetch_one(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} "
|
||||
"FROM presence_overrides "
|
||||
"WHERE child_id = ? AND end_date >= ? "
|
||||
"AND start_date <= ? ORDER BY id LIMIT 1",
|
||||
(child_id, start_date, end_date),
|
||||
)
|
||||
else:
|
||||
conflicting = None
|
||||
if conflicting is not None:
|
||||
conflict = _override_from_row(conflicting)
|
||||
raise ValueError(
|
||||
f"override for child {child_id} "
|
||||
f"[{start_date}, {end_date}] overlaps existing "
|
||||
f"override {conflict.id} "
|
||||
f"[{conflict.start_date}, {conflict.end_date}] "
|
||||
f"(is_present={conflict.is_present})"
|
||||
)
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO presence_overrides (child_id, "
|
||||
"start_date, end_date, is_present, note) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(child_id, start_date, end_date, int(is_present), note),
|
||||
)
|
||||
override = await self._database.fetch_one(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
"WHERE id = ?",
|
||||
(result.lastrowid,),
|
||||
)
|
||||
assert override is not None
|
||||
return _override_from_row(override)
|
||||
|
||||
async def list_by_child_and_range(
|
||||
self,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
) -> list[PresenceOverrideRecord]:
|
||||
"""Return the child's overrides overlapping [range_start, range_end].
|
||||
|
||||
An override overlaps the range when it ends on or after the
|
||||
range starts and starts on or before the range ends (inclusive
|
||||
boundary dates, matching the storage model where overrides are
|
||||
closed date ranges). Ordered by start date.
|
||||
"""
|
||||
_validate_date(range_start, "range_start")
|
||||
_validate_date(range_end, "range_end")
|
||||
if range_end < range_start:
|
||||
raise ValueError(
|
||||
"range_end must be on or after range_start, got "
|
||||
f"{range_end!r} < {range_start!r}"
|
||||
)
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
"WHERE child_id = ? AND end_date >= ? AND start_date <= ? "
|
||||
"ORDER BY start_date",
|
||||
(child_id, range_start, range_end),
|
||||
)
|
||||
return [_override_from_row(row) for row in rows]
|
||||
|
||||
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
|
||||
"""Return one override by id, or None when it does not exist."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
"WHERE id = ?",
|
||||
(override_id,),
|
||||
)
|
||||
return _override_from_row(row) if row is not None else None
|
||||
|
||||
async def delete(self, override_id: int) -> bool:
|
||||
"""Remove one override by id; True if a row was removed."""
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM presence_overrides WHERE id = ?",
|
||||
(override_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def read_snapshot_unlocked(
|
||||
database: NestQuestDatabase,
|
||||
child_ids: list[int],
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
) -> tuple[
|
||||
dict[int, PresenceScheduleRecord],
|
||||
dict[int, list[PresenceOverrideRecord]],
|
||||
]:
|
||||
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
|
||||
|
||||
MUST be called inside the connection lock and an open transaction, so
|
||||
the schedules and overrides returned here stay coherent with every
|
||||
other table the caller reads inside that same transaction (the
|
||||
materialization walk's single input snapshot does exactly that). The
|
||||
date range comes already validated by the caller.
|
||||
|
||||
Returns ``(schedules, overrides)``:
|
||||
|
||||
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
|
||||
for every child that HAS a schedule row. A child absent from the
|
||||
mapping has no schedule, i.e. is present every day (Feature 05).
|
||||
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
|
||||
``[range_start, range_end]`` (inclusive boundaries), ordered by start
|
||||
date; a child with no matching override is absent from the mapping.
|
||||
|
||||
``child_ids`` must name existing children (the caller derives them
|
||||
from a trusted snapshot, within the same transaction).
|
||||
"""
|
||||
children = sorted(set(child_ids))
|
||||
if not children:
|
||||
return {}, {}
|
||||
placeholders = ",".join("?" for _ in children)
|
||||
schedule_rows = await database.fetch_all(
|
||||
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
|
||||
f"WHERE child_id IN ({placeholders})",
|
||||
tuple(children),
|
||||
)
|
||||
override_rows = await database.fetch_all(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
f"WHERE child_id IN ({placeholders}) AND end_date >= ? "
|
||||
"AND start_date <= ? ORDER BY start_date",
|
||||
tuple(children) + (range_start, range_end),
|
||||
)
|
||||
schedules = {
|
||||
record.child_id: record
|
||||
for record in map(_schedule_from_row, schedule_rows)
|
||||
}
|
||||
overrides: dict[int, list[PresenceOverrideRecord]] = {}
|
||||
for row in override_rows:
|
||||
record = _override_from_row(row)
|
||||
overrides.setdefault(record.child_id, []).append(record)
|
||||
return schedules, overrides
|
||||
from .core.dao_presence import * # noqa: F401,F403
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,422 +1,31 @@
|
||||
"""Async SQLite connection wrapper for the NestQuest integration."""
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package.
|
||||
|
||||
The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task
|
||||
d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes
|
||||
an executor callable rather than a ``hass`` object. This module
|
||||
re-exports it and provides :func:`make_database`, a thin adapter that
|
||||
constructs a wrapper backed by ``hass.async_add_executor_job``, so the
|
||||
integration constructs the database the same way it always did.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from collections.abc import AsyncIterator, Awaitable, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .core.db import ExecutionResult, NestQuestDatabase
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionResult:
|
||||
"""Plain-data outcome of one statement.
|
||||
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
|
||||
|
||||
``rowcount`` and ``lastrowid`` mirror the corresponding
|
||||
:class:`sqlite3.Cursor` attributes; both are captured on the executor
|
||||
thread so no sqlite3 object ever crosses back to the event loop.
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def make_database(hass: HomeAssistant) -> NestQuestDatabase:
|
||||
"""Construct a :class:`NestQuestDatabase` wired to ``hass``'s executor.
|
||||
|
||||
Mirrors the pre-extraction ``NestQuestDatabase(hass)`` call: the
|
||||
wrapper's executor is ``hass.async_add_executor_job``, so every
|
||||
sqlite3 call runs on the HA executor thread and the event loop
|
||||
never blocks.
|
||||
"""
|
||||
|
||||
rowcount: int
|
||||
lastrowid: int | None
|
||||
|
||||
|
||||
class NestQuestDatabase:
|
||||
"""Async wrapper around a single SQLite connection.
|
||||
|
||||
Every sqlite3 call is delegated to the Home Assistant executor through
|
||||
``hass.async_add_executor_job`` so the event loop is never blocked, and
|
||||
only plain Python data (row tuples and :class:`ExecutionResult`) is
|
||||
returned to the event loop.
|
||||
|
||||
Every operation is serialized through one asyncio.Lock per wrapper. A
|
||||
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
|
||||
interleaved coroutines cannot corrupt the transaction and concurrent
|
||||
BEGINs are impossible; statements issued while a transaction is open
|
||||
queue until it completes. Concurrent transactions on one wrapper are
|
||||
not supported: a second ``transaction()`` entered while one is active
|
||||
raises :class:`RuntimeError`. ``close()`` serializes on the same lock
|
||||
and is idempotent.
|
||||
|
||||
Every connection-affecting executor job (connect, pragmas, statements,
|
||||
transaction lifecycle and close) is awaited through the settlement
|
||||
helper :meth:`_await_settled`: if the awaiting task is cancelled while
|
||||
a job is in flight, the job still settles on the worker thread before
|
||||
the cancellation propagates, so the wrapper lock is never released
|
||||
while the executor thread is mid-job.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the wrapper around a Home Assistant instance."""
|
||||
self._hass = hass
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
self._rowcount: int = -1
|
||||
self._lastrowid: int | None = None
|
||||
self._lock: asyncio.Lock | None = None
|
||||
self._lock_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._in_transaction = False
|
||||
self._tx_task: asyncio.Task | None = None
|
||||
|
||||
def _get_lock(self) -> asyncio.Lock:
|
||||
"""Return the per-wrapper lock, (re)bound to the running loop.
|
||||
|
||||
HA drives every wrapper call from one event loop, so the lock is
|
||||
created once and reused; re-binding only when the running loop
|
||||
differs keeps the wrapper usable from plain synchronous drivers
|
||||
that run each call on a fresh loop.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
if self._lock is None or self._lock_loop is not loop:
|
||||
self._lock = asyncio.Lock()
|
||||
self._lock_loop = loop
|
||||
return self._lock
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Return True while the underlying connection is open."""
|
||||
return self._conn is not None
|
||||
|
||||
@property
|
||||
def rowcount(self) -> int:
|
||||
"""Row count of the most recent statement, or -1 when unset."""
|
||||
return self._rowcount
|
||||
|
||||
@property
|
||||
def lastrowid(self) -> int | None:
|
||||
"""Row id of the most recent insert, or None when unset."""
|
||||
return self._lastrowid
|
||||
|
||||
@asynccontextmanager
|
||||
async def _serialized(self) -> AsyncIterator[None]:
|
||||
"""Hold the wrapper lock unless the caller owns the active transaction.
|
||||
|
||||
A transaction holds the lock for its whole BEGIN..COMMIT span;
|
||||
statements issued by the transaction's own task are already
|
||||
serialized by it and must not re-acquire the non-reentrant lock.
|
||||
Any other task queues on the lock until the transaction ends.
|
||||
"""
|
||||
if (
|
||||
self._in_transaction
|
||||
and self._tx_task is not None
|
||||
and asyncio.current_task() is self._tx_task
|
||||
):
|
||||
yield
|
||||
return
|
||||
async with self._get_lock():
|
||||
yield
|
||||
|
||||
async def open(self, path: Path | str) -> NestQuestDatabase:
|
||||
"""Open the SQLite file with WAL journal mode and foreign keys on.
|
||||
|
||||
Cancellation-safe: if this call is cancelled while the connect job
|
||||
is in flight, the job still settles (see :meth:`_await_settled`).
|
||||
A connection the settled job created is never adopted by the
|
||||
cancelled call; it is closed through a settled executor job instead
|
||||
of being abandoned to garbage collection, and the cancellation
|
||||
propagates afterwards, so a follow-up ``open()`` starts from a
|
||||
clean state.
|
||||
"""
|
||||
if self._conn is not None:
|
||||
return self
|
||||
async with self._get_lock():
|
||||
if self._conn is not None:
|
||||
return self
|
||||
|
||||
def _open() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(
|
||||
path, check_same_thread=False, isolation_level=None
|
||||
)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
except BaseException:
|
||||
conn.close()
|
||||
raise
|
||||
return conn
|
||||
|
||||
settled, cancelled = await self._await_settled_state(
|
||||
self._hass.async_add_executor_job(_open)
|
||||
)
|
||||
if cancelled:
|
||||
if isinstance(settled, sqlite3.Connection):
|
||||
|
||||
def _close_abandoned() -> None:
|
||||
settled.close()
|
||||
|
||||
await self._await_settled(
|
||||
self._hass.async_add_executor_job(_close_abandoned)
|
||||
)
|
||||
raise asyncio.CancelledError()
|
||||
self._conn = settled
|
||||
return self
|
||||
|
||||
async def execute(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> ExecutionResult:
|
||||
"""Run one statement in the executor and return its plain result."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _execute() -> ExecutionResult:
|
||||
cursor = conn.execute(sql, parameters)
|
||||
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
||||
|
||||
result = await self._await_settled(
|
||||
self._hass.async_add_executor_job(_execute)
|
||||
)
|
||||
self._rowcount = result.rowcount
|
||||
self._lastrowid = result.lastrowid
|
||||
return result
|
||||
|
||||
async def execute_many(
|
||||
self, sql: str, parameters: Sequence[Sequence[Any]]
|
||||
) -> ExecutionResult:
|
||||
"""Run executemany in the executor and return its plain result."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _execute_many() -> ExecutionResult:
|
||||
cursor = conn.executemany(sql, parameters)
|
||||
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
||||
|
||||
result = await self._await_settled(
|
||||
self._hass.async_add_executor_job(_execute_many)
|
||||
)
|
||||
self._rowcount = result.rowcount
|
||||
self._lastrowid = result.lastrowid
|
||||
return result
|
||||
|
||||
async def fetch_one(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> tuple | None:
|
||||
"""Run a query in the executor and return its first row, or None."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _fetch_one() -> tuple[tuple | None, int, int | None]:
|
||||
cursor = conn.execute(sql, parameters)
|
||||
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
|
||||
|
||||
row, rowcount, lastrowid = await self._await_settled(
|
||||
self._hass.async_add_executor_job(_fetch_one)
|
||||
)
|
||||
self._rowcount = rowcount
|
||||
self._lastrowid = lastrowid
|
||||
return row
|
||||
|
||||
async def fetch_all(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> list[tuple]:
|
||||
"""Run a query in the executor and return all of its rows."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _fetch_all() -> tuple[list[tuple], int, int | None]:
|
||||
cursor = conn.execute(sql, parameters)
|
||||
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
|
||||
|
||||
rows, rowcount, lastrowid = await self._await_settled(
|
||||
self._hass.async_add_executor_job(_fetch_all)
|
||||
)
|
||||
self._rowcount = rowcount
|
||||
self._lastrowid = lastrowid
|
||||
return rows
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self) -> AsyncIterator[None]:
|
||||
"""Run a block inside BEGIN/COMMIT, rolling back on any exception.
|
||||
|
||||
The wrapper lock is held for the entire BEGIN..COMMIT span, so no
|
||||
other statement can interleave inside the transaction. Entering a
|
||||
second transaction while one is active raises RuntimeError. A
|
||||
failing COMMIT (e.g. a deferred foreign-key violation) also triggers
|
||||
a ROLLBACK before the commit error propagates, so the real
|
||||
connection is never left inside a stray transaction.
|
||||
|
||||
The lifecycle is cancellation-safe. Transaction ownership and the
|
||||
wrapper lock are only released after the executor work has fully
|
||||
settled, even when the owning task is cancelled mid-flight: if a
|
||||
cancelled BEGIN or COMMIT still completes on the worker thread,
|
||||
the corresponding ROLLBACK is shielded and awaited before any state
|
||||
is cleared. A lifecycle statement that fails plus a failing
|
||||
follow-up cleanup surface both errors in an ExceptionGroup.
|
||||
"""
|
||||
if self._in_transaction:
|
||||
raise RuntimeError(
|
||||
"A NestQuest transaction is already active on this "
|
||||
"connection; concurrent transactions are not supported"
|
||||
)
|
||||
async with self._get_lock():
|
||||
conn = self._require_conn()
|
||||
self._in_transaction = True
|
||||
self._tx_task = asyncio.current_task()
|
||||
try:
|
||||
|
||||
def _begin() -> None:
|
||||
conn.execute("BEGIN")
|
||||
|
||||
await self._settle_lifecycle(
|
||||
conn,
|
||||
self._hass.async_add_executor_job(_begin),
|
||||
"BEGIN did not settle cleanly and the follow-up "
|
||||
"cleanup also raised; the transaction may need manual "
|
||||
"cleanup",
|
||||
)
|
||||
try:
|
||||
yield
|
||||
except BaseException:
|
||||
|
||||
def _rollback() -> None:
|
||||
conn.execute("ROLLBACK")
|
||||
|
||||
await self._settle_lifecycle(
|
||||
conn,
|
||||
self._hass.async_add_executor_job(_rollback),
|
||||
"ROLLBACK failed and the follow-up cleanup also "
|
||||
"raised; the transaction may need manual cleanup",
|
||||
)
|
||||
raise
|
||||
|
||||
def _commit() -> None:
|
||||
conn.execute("COMMIT")
|
||||
|
||||
await self._settle_lifecycle(
|
||||
conn,
|
||||
self._hass.async_add_executor_job(_commit),
|
||||
"COMMIT failed and the follow-up cleanup also raised; "
|
||||
"the transaction may need manual cleanup",
|
||||
)
|
||||
finally:
|
||||
self._in_transaction = False
|
||||
self._tx_task = None
|
||||
|
||||
async def _settle_lifecycle(
|
||||
self, conn: sqlite3.Connection, job: Awaitable[Any], message: str
|
||||
) -> None:
|
||||
"""Run one BEGIN/ROLLBACK/COMMIT job, then force the connection clean.
|
||||
|
||||
The job always settles first (see :meth:`_await_settled`), so a
|
||||
cancelled statement that the worker thread completed anyway is
|
||||
never abandoned mid-flight. If the job raised, a follow-up
|
||||
ROLLBACK — itself settled — guarantees the connection ends outside
|
||||
any SQLite transaction before the error propagates. When both the
|
||||
job and the cleanup raise, both errors are preserved in an
|
||||
ExceptionGroup instead of the original being lost.
|
||||
"""
|
||||
try:
|
||||
await self._await_settled(job)
|
||||
except BaseException as settle_error:
|
||||
errors: list[BaseException] = [settle_error]
|
||||
try:
|
||||
await self._ensure_no_stray_transaction(conn)
|
||||
except BaseException as cleanup_error:
|
||||
errors.append(cleanup_error)
|
||||
if len(errors) == 2:
|
||||
raise BaseExceptionGroup(message, errors) from settle_error
|
||||
raise
|
||||
|
||||
async def _ensure_no_stray_transaction(
|
||||
self, conn: sqlite3.Connection
|
||||
) -> None:
|
||||
"""Force the connection out of any transaction via ROLLBACK.
|
||||
|
||||
Runs after a failed or cancelled lifecycle statement. The worker
|
||||
thread may still be finishing (or may already have completed) the
|
||||
very statement whose await was cancelled, so both the check and
|
||||
the ROLLBACK settle in the executor before the caller releases
|
||||
transaction ownership.
|
||||
"""
|
||||
|
||||
def _cleanup() -> bool:
|
||||
if conn.in_transaction:
|
||||
conn.execute("ROLLBACK")
|
||||
return conn.in_transaction
|
||||
|
||||
stray = await self._await_settled(
|
||||
self._hass.async_add_executor_job(_cleanup)
|
||||
)
|
||||
if stray:
|
||||
raise RuntimeError(
|
||||
"The SQLite connection is still inside a transaction after "
|
||||
"cleanup; the transaction may need manual cleanup"
|
||||
)
|
||||
|
||||
async def _await_settled(self, job: Awaitable[Any]) -> Any:
|
||||
"""Await ``job``, letting it settle even if this task is cancelled.
|
||||
|
||||
Cancellation is observed, not swallowed: it is re-raised only once
|
||||
the job has fully settled, so the caller can finalize against a
|
||||
known connection state instead of abandoning in-flight work. The
|
||||
job is wrapped in a task because a bare coroutine dies un-awaited
|
||||
once its await is cancelled. A second cancellation during
|
||||
settlement is absorbed the same way; if the job itself fails after
|
||||
a cancellation, both errors surface in an ExceptionGroup.
|
||||
"""
|
||||
result, cancelled = await self._await_settled_state(job)
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError()
|
||||
return result
|
||||
|
||||
async def _await_settled_state(self, job: Awaitable[Any]) -> tuple[Any, bool]:
|
||||
"""Await ``job`` to settlement and report ``(result, cancelled)``.
|
||||
|
||||
Same settlement guarantees as :meth:`_await_settled`, but returns
|
||||
the settled outcome together with whether the awaiting task was
|
||||
cancelled, so callers that must act on a settled result before
|
||||
re-raising cancellation (e.g. closing an abandoned connection)
|
||||
can do so without losing the result.
|
||||
"""
|
||||
cancelled = False
|
||||
inner = asyncio.ensure_future(job)
|
||||
while not inner.done():
|
||||
try:
|
||||
await asyncio.shield(inner)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
try:
|
||||
result = inner.result()
|
||||
except BaseException as job_error:
|
||||
if cancelled:
|
||||
raise BaseExceptionGroup(
|
||||
"executor job failed while settling after cancellation",
|
||||
[asyncio.CancelledError(), job_error],
|
||||
) from job_error
|
||||
raise
|
||||
return result, cancelled
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the connection in the executor; safe to call repeatedly.
|
||||
|
||||
Serialized on the wrapper lock, so an in-flight operation or open
|
||||
transaction always completes before the connection is closed.
|
||||
"""
|
||||
if (
|
||||
self._in_transaction
|
||||
and self._tx_task is not None
|
||||
and asyncio.current_task() is self._tx_task
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Cannot close the NestQuest database from inside its own "
|
||||
"transaction; exit the transaction first"
|
||||
)
|
||||
async with self._get_lock():
|
||||
conn, self._conn = self._conn, None
|
||||
self._rowcount, self._lastrowid = -1, None
|
||||
if conn is None:
|
||||
return
|
||||
|
||||
def _close() -> None:
|
||||
conn.close()
|
||||
|
||||
await self._await_settled(self._hass.async_add_executor_job(_close))
|
||||
|
||||
def _require_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError(
|
||||
"The NestQuest database connection is closed; call open() first"
|
||||
)
|
||||
return self._conn
|
||||
return NestQuestDatabase(hass.async_add_executor_job)
|
||||
|
||||
@@ -1,73 +1,37 @@
|
||||
"""HA bus events fired by the NestQuest service paths (Feature 10).
|
||||
"""Re-export / HA wiring of :mod:`custom_components.nestquest.core.events` for the integration package.
|
||||
|
||||
The four documented events (design/ENTITIES-AND-SERVICES.md §3, names
|
||||
in :mod:`~.const`): ``nestquest_quest_completed`` and
|
||||
``nestquest_quest_uncompleted`` fire from the completion service
|
||||
handlers on an actual transition (a no-op re-complete or re-uncomplete
|
||||
fires nothing), and ``nestquest_child_day_complete`` fires when a
|
||||
completion clears the child's whole day (quests were owed and none
|
||||
remain — a zero-quest day can never fire it, matching the all-done
|
||||
binary sensor's rule). ``nestquest_quest_missed`` is the documented
|
||||
contract Feature 11's nightly sweep fires; nothing here fires it.
|
||||
The transition-event payload construction lives in
|
||||
:mod:`custom_components.nestquest.core.events` (extracted in
|
||||
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
|
||||
shim keeps the integration's old ``fire_quest_completed`` /
|
||||
``fire_quest_uncompleted`` API (the complete/uncomplete service
|
||||
handlers call them with ``hass``), reads the HA-local timezone from
|
||||
``hass.config.time_zone`` to derive ``today``, asks the core builder
|
||||
for the events, and fires each on ``hass.bus`` with the documented
|
||||
payloads. The four blueprints keep working with identical payloads.
|
||||
|
||||
Payload policy: one dict carrying ``child_id``, ``child_name``,
|
||||
``instance_id``, ``quest_title``, the instance's ``window``/
|
||||
``due_date``/``due_time`` so automation authors can filter without a
|
||||
database lookup, and ``occurred_at`` — the event's own strict UTC
|
||||
ISO-8601 timestamp. Completion payloads additionally carry
|
||||
``was_on_time``. No other personal data: names and titles are the
|
||||
household's own records, and nothing beyond them leaks.
|
||||
The completed event is fired BEFORE the day-complete rule evaluates:
|
||||
``fire_quest_completed`` asks
|
||||
:func:`build_quest_completed_event` for the completed event and fires
|
||||
it immediately, THEN reads the HA-local ``today``, asks
|
||||
:func:`build_child_day_complete_event` for the day-complete event, and
|
||||
fires it (if any) afterwards. This restores the pre-extraction
|
||||
ordering — a failure in the day-complete DB reads or the ZoneInfo
|
||||
lookup cannot suppress the completed event.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .completion import derive_state
|
||||
from .const import (
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
from .core.db import NestQuestDatabase
|
||||
from .core.events import (
|
||||
build_child_day_complete_event,
|
||||
build_quest_completed_event,
|
||||
build_quest_uncompleted_events,
|
||||
)
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import CompletionEventsDao, QuestInstancesDao
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
|
||||
#: explicit offset, second precision.
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
async def _instance_payload(
|
||||
database: NestQuestDatabase, instance_id: int, now_stamp: str
|
||||
) -> dict:
|
||||
"""Build the shared event payload for one quest instance."""
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
child = await ChildrenDao(database).get(instance.child_id)
|
||||
definition = await QuestDefinitionsDao(database).get(
|
||||
instance.definition_id
|
||||
)
|
||||
return {
|
||||
"child_id": instance.child_id,
|
||||
"child_name": child.display_name if child else None,
|
||||
"instance_id": instance.id,
|
||||
"quest_title": definition.title if definition else None,
|
||||
"window": instance.window,
|
||||
"due_date": instance.due_date,
|
||||
"due_time": instance.due_time,
|
||||
"occurred_at": now_stamp,
|
||||
}
|
||||
__all__ = ["fire_quest_completed", "fire_quest_uncompleted"]
|
||||
|
||||
|
||||
async def fire_quest_completed(
|
||||
@@ -80,51 +44,36 @@ async def fire_quest_completed(
|
||||
"""Fire ``nestquest_quest_completed`` and, when the completion
|
||||
cleared the child's whole day, ``nestquest_child_day_complete``.
|
||||
|
||||
Called by the complete_quest service handler AFTER an actual
|
||||
completion transition, with the completion's ``was_on_time`` —
|
||||
computed under the completion lock and carried on the result — so
|
||||
the payload never re-reads mutable latest state a racing reversal
|
||||
could have replaced. A no-op re-complete never reaches here.
|
||||
Thin HA wiring over the HA-free
|
||||
:func:`custom_components.nestquest.core.events.build_quest_completed_event`
|
||||
and
|
||||
:func:`build_child_day_complete_event`: the completed event is
|
||||
built and fired FIRST (so a failure in the day-complete DB reads
|
||||
or the HA-local ``today`` lookup cannot suppress it), then the
|
||||
HA-local ``today`` (the day-complete rule's input) is read from
|
||||
``hass.config.time_zone`` and the day-complete builder is asked
|
||||
whether the child's whole day cleared. Identical payloads and
|
||||
day-complete rule to the pre-extraction implementation.
|
||||
"""
|
||||
now_stamp = _now_stamp()
|
||||
payload = await _instance_payload(database, instance_id, now_stamp)
|
||||
payload["was_on_time"] = was_on_time
|
||||
hass.bus.async_fire(EVENT_QUEST_COMPLETED, payload)
|
||||
|
||||
# Day-complete: quests were owed today and none remain (the same
|
||||
# rule as the all-done binary sensor; a zero-quest day never fires).
|
||||
# Only a completion of TODAY'S instance evaluates it: completing a
|
||||
# back-dated or future instance must not announce a cleared day.
|
||||
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
if payload["due_date"] != today.isoformat():
|
||||
return
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
payload["child_id"], today.isoformat(), today.isoformat()
|
||||
completed = await build_quest_completed_event(
|
||||
database,
|
||||
instance_id,
|
||||
was_on_time=was_on_time,
|
||||
)
|
||||
events = CompletionEventsDao(database)
|
||||
remaining = 0
|
||||
for instance in instances:
|
||||
latest_for_instance = await events.get_latest_for_instance(
|
||||
instance.id
|
||||
)
|
||||
if derive_state(instance, latest_for_instance, today) != "done":
|
||||
remaining += 1
|
||||
if instances and remaining == 0:
|
||||
hass.bus.async_fire(
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
{
|
||||
"child_id": payload["child_id"],
|
||||
"child_name": payload["child_name"],
|
||||
"quests_due": len(instances),
|
||||
"quests_completed": len(instances),
|
||||
"occurred_at": now_stamp,
|
||||
},
|
||||
)
|
||||
hass.bus.async_fire(*completed)
|
||||
|
||||
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
day_complete = await build_child_day_complete_event(
|
||||
database, completed[1], today=today
|
||||
)
|
||||
if day_complete is not None:
|
||||
hass.bus.async_fire(*day_complete)
|
||||
|
||||
|
||||
async def fire_quest_uncompleted(
|
||||
hass, database: NestQuestDatabase, instance_id: int
|
||||
) -> None:
|
||||
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
|
||||
payload = await _instance_payload(database, instance_id, _now_stamp())
|
||||
hass.bus.async_fire(EVENT_QUEST_UNCOMPLETED, payload)
|
||||
events = await build_quest_uncompleted_events(database, instance_id)
|
||||
for event_type, payload in events:
|
||||
hass.bus.async_fire(event_type, payload)
|
||||
|
||||
@@ -1,377 +1,11 @@
|
||||
"""The materialization walk (Feature 07 core).
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.materialize` for the integration package.
|
||||
|
||||
:func:`materialize` turns the schedule rules + custody presence of the
|
||||
active quest definitions into concrete ``quest_instances`` rows over a
|
||||
date range. It is the pure walk over definitions x dates x assignees x
|
||||
windows; HA scheduling (day-rollover triggers) is a separate later
|
||||
task and lives elsewhere.
|
||||
|
||||
The walk is deterministic and idempotent:
|
||||
|
||||
- All inputs — the active definitions with their rules, assignees and
|
||||
windows, AND the assignee children's presence schedules and scoped
|
||||
overrides — are read inside ONE locked transaction (see
|
||||
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
|
||||
single coherent snapshot rather than a definition state from one
|
||||
instant and a presence state from another.
|
||||
- Presence is snapshotted into an immutable
|
||||
:class:`~.presence.PresenceEngine` from that same coherent read.
|
||||
- For each date in the range, each definition whose decoded rule
|
||||
:func:`~.recurrence.occurs_on` on that date, each assignee who is
|
||||
``is_present`` on that date and each declared window, one instance is
|
||||
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
|
||||
with a ``generated_at`` stamp shared by the whole batch (one coherent
|
||||
time per run).
|
||||
|
||||
A config change that lands AFTER the snapshot is read but BEFORE a
|
||||
tuple's write (an assignment removed, a definition or child deactivated,
|
||||
a window removed) makes that tuple SKIP rather than abort the batch:
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
|
||||
every materialization precondition (active definition, active child,
|
||||
live assignment, declared window, and its CURRENT ``due_time``) inside
|
||||
the SAME transaction as the INSERT, so the check and the write cannot be
|
||||
split by a racing config edit.
|
||||
|
||||
The upsert is idempotent on (definition_id, child_id, due_date,
|
||||
window), so re-running the materialization over the same range never
|
||||
duplicates a row; a tuple whose instance is already completed is SKIPPED
|
||||
(never rewritten, never raised) by
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
|
||||
|
||||
Regeneration: a config or presence change must rebuild future instances
|
||||
so they track the new state. :func:`regenerate_for_definition` covers
|
||||
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
|
||||
covers a single child's presence change (Feature 09). Both delete the
|
||||
affected open instances at or after today — never a completed instance,
|
||||
never the past — and re-run the walk over the rolling horizon
|
||||
``[today, today + horizon_days]`` (``horizon_days`` defaults to
|
||||
``DEFAULT_HORIZON_DAYS``).
|
||||
|
||||
HOOK (Feature 09 presence services): the presence business layer that
|
||||
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
|
||||
presence-state write so a child's future instances track its presence:
|
||||
``set_presence_pattern`` (including clearing/setting an empty pattern),
|
||||
deleting/clearing the presence schedule
|
||||
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||
``create_presence_override``, and ``delete_presence_override``. A
|
||||
schedule delete changes presence state too (the child becomes
|
||||
always-present), so it must regenerate just like the others. This hook
|
||||
is documented, not yet wired: no presence business layer exists yet, and
|
||||
the presence DAO (``dao_presence.py``) must stay free of business rules
|
||||
— never call :func:`regenerate_for_child` from inside the DAO layer.
|
||||
The materialization walk lives in
|
||||
:mod:`custom_components.nestquest.core.materialize` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .materialize import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from .const import DEFAULT_HORIZON_DAYS
|
||||
from .dao_instances import QuestInstancesDao, _resolve_today
|
||||
from .dao_rules import (
|
||||
_validate_date,
|
||||
load_materialization_input,
|
||||
schedule_rule_from_storage,
|
||||
schedule_rule_storage_from_record,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
|
||||
from .recurrence import ScheduleRule, occurs_on
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the batch's shared UTC second-precision generation stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_horizon_days(horizon_days: int | None) -> int:
|
||||
"""Validate a regeneration horizon and return the resolved day count.
|
||||
|
||||
``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool
|
||||
(SQLite would happily bind ``True``/``False`` onto an int), a non-int,
|
||||
or a value below 1 raises ValueError naming the field — the validation
|
||||
runs BEFORE any future-instance delete so a malformed horizon can never
|
||||
delete rows it then fails to rebuild. A negative horizon would
|
||||
otherwise delete first and only then be rejected by
|
||||
:func:`materialize`'s inverted-range check; ``0``/``False`` would
|
||||
silently shrink the window to a single day.
|
||||
"""
|
||||
if horizon_days is None:
|
||||
return DEFAULT_HORIZON_DAYS
|
||||
if (
|
||||
isinstance(horizon_days, bool)
|
||||
or not isinstance(horizon_days, int)
|
||||
or horizon_days < 1
|
||||
):
|
||||
raise ValueError(
|
||||
f"horizon_days must be a positive integer, got {horizon_days!r}"
|
||||
)
|
||||
return horizon_days
|
||||
|
||||
|
||||
def _decode_rule(snapshot) -> ScheduleRule:
|
||||
"""Decode a snapshot's rule record back into a ScheduleRule.
|
||||
|
||||
The record carried by the snapshot is the one the definition
|
||||
referenced at read time, so decoding it here (rather than re-reading
|
||||
through ``ScheduleRulesDao``) preserves the coherence the snapshot
|
||||
guarantees.
|
||||
"""
|
||||
return schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(snapshot.rule)
|
||||
)
|
||||
|
||||
|
||||
def _build_engine(
|
||||
schedules_records,
|
||||
overrides_records,
|
||||
) -> PresenceEngine:
|
||||
"""Convert raw presence records into an immutable PresenceEngine."""
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
for child_id, record in schedules_records.items():
|
||||
schedules[child_id] = PresenceSchedule.decode(
|
||||
child_id, record.anchor_date, record.pattern
|
||||
)
|
||||
overrides: dict[int, list[PresenceOverride]] = {}
|
||||
for child_id, records in overrides_records.items():
|
||||
overrides[child_id] = [
|
||||
PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in records
|
||||
]
|
||||
return PresenceEngine(schedules, overrides)
|
||||
|
||||
|
||||
async def materialize(
|
||||
database: NestQuestDatabase,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
child_ids: list[int] | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> int:
|
||||
"""Generate quest instances for the closed range [start_date, end_date].
|
||||
|
||||
``start_date`` and ``end_date`` must be strict ISO calendar dates
|
||||
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
|
||||
else raises ValueError naming the offending field. Returns the number
|
||||
of instances upserted for the range (idempotent — a re-run refreshes
|
||||
the same rows, never duplicates them).
|
||||
|
||||
``child_ids`` optionally scopes the walk to exactly those children:
|
||||
assignees outside the list are never written, so a caller that only
|
||||
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
|
||||
after a presence change) cannot touch any unrelated child's rows.
|
||||
When omitted (or None) the walk covers every assignee as before.
|
||||
|
||||
``today`` optionally pins the resolved HA-local date the walk's
|
||||
no-past guard compares against; it is threaded into every
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
|
||||
omitted the host clock (``datetime.date.today``) is used, which is the
|
||||
historical default. A caller that computed the horizon in Home
|
||||
Assistant's configured time zone MUST pass that same date here so a
|
||||
time zone behind the host around midnight is not rejected as "past".
|
||||
|
||||
``start_date`` is clamped up to the resolved "today" (the same
|
||||
``today`` anchor threaded into the no-past guard), so a caller that
|
||||
passes a ``start_date`` already in the past never yields a
|
||||
past-dated instance: the walk simply begins on today and skips every
|
||||
earlier date. A range entirely in the past therefore produces zero
|
||||
instances rather than an error, and the DAO's no-past guard remains
|
||||
the authoritative backstop under the lock.
|
||||
|
||||
All inputs are read in ONE locked transaction; each tuple's write then
|
||||
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
|
||||
which re-validates the tuple and its current ``due_time`` in the same
|
||||
transaction as the INSERT, so a config change that arrives mid-batch
|
||||
skips the affected tuples rather than aborting the batch or leaving a
|
||||
partial result.
|
||||
"""
|
||||
_validate_date(start_date, "start_date")
|
||||
_validate_date(end_date, "end_date")
|
||||
if end_date < start_date:
|
||||
raise ValueError(
|
||||
f"end_date must be on or after start_date, got "
|
||||
f"{end_date!r} < {start_date!r}"
|
||||
)
|
||||
|
||||
snapshots, schedules_records, overrides_records = await (
|
||||
load_materialization_input(database, start_date, end_date)
|
||||
)
|
||||
engine = _build_engine(schedules_records, overrides_records)
|
||||
child_filter = None if child_ids is None else set(child_ids)
|
||||
|
||||
definitions = [
|
||||
(snapshot.definition.id, _decode_rule(snapshot),
|
||||
snapshot.assignees, snapshot.windows)
|
||||
for snapshot in snapshots
|
||||
]
|
||||
|
||||
generated_at = _now_stamp()
|
||||
instances = QuestInstancesDao(database)
|
||||
|
||||
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
|
||||
# Clamp a past start up to today (the HA-local anchor when the caller
|
||||
# pinned one, else the host clock) so the walk can never emit a
|
||||
# past-dated instance; the DAO's no-past guard remains the backstop.
|
||||
# A range entirely in the past therefore yields zero instances.
|
||||
today_date = _resolve_today(today)
|
||||
start = max(start, today_date)
|
||||
|
||||
count = 0
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
iso = cursor.isoformat()
|
||||
for definition_id, rule, assignees, windows in definitions:
|
||||
if not occurs_on(rule, cursor):
|
||||
continue
|
||||
for child in assignees:
|
||||
if child_filter is not None and child.id not in child_filter:
|
||||
continue
|
||||
if not engine.is_present(child.id, cursor):
|
||||
continue
|
||||
for window in windows:
|
||||
written = await instances.upsert_if_valid(
|
||||
definition_id,
|
||||
child.id,
|
||||
iso,
|
||||
generated_at,
|
||||
window=window.window,
|
||||
due_time=window.due_time,
|
||||
today=today,
|
||||
)
|
||||
if written is not None:
|
||||
count += 1
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return count
|
||||
|
||||
|
||||
async def regenerate_for_definition(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> int:
|
||||
"""Regenerate a definition's future instances after a config change.
|
||||
|
||||
The Feature 06 edit/assignment/activation paths call this after a
|
||||
successful write so future instances track the new state: the
|
||||
definition's open instances at or after today are deleted (never a
|
||||
completed instance, never the past) via
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
|
||||
then the materialization walk re-runs over the rolling horizon
|
||||
``[today, today + horizon_days]`` so the (changed) rule,
|
||||
assignee set and windows re-materialize against today's state.
|
||||
|
||||
"today" is computed the same way the walk computes it —
|
||||
:func:`~.dao_instances._today` — so the delete cutoff and the
|
||||
re-materialization range share one anchor, and the walk's
|
||||
``upsert_if_valid`` no-past guard never rejects the regenerated
|
||||
range. ``today`` optionally pins a caller-resolved HA-local date
|
||||
(threaded into both the delete cutoff and the re-materialization)
|
||||
so a household time zone behind the host around midnight stays
|
||||
consistent. ``horizon_days`` sizes the re-materialization window;
|
||||
when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`.
|
||||
A bool, non-int, or sub-1 value raises ValueError BEFORE any delete.
|
||||
Returns the number of instances the re-materialization
|
||||
upserted across the whole snapshot (idempotent — other active
|
||||
definitions' tuples refresh in place, never duplicate).
|
||||
"""
|
||||
horizon = _resolve_horizon_days(horizon_days)
|
||||
today_date = _resolve_today(today)
|
||||
start_date = today_date.isoformat()
|
||||
end_date = (
|
||||
today_date + datetime.timedelta(days=horizon)
|
||||
).isoformat()
|
||||
|
||||
instances = QuestInstancesDao(database)
|
||||
await instances.delete_future_uncompleted(
|
||||
definition_id, start_date, today=today
|
||||
)
|
||||
return await materialize(database, start_date, end_date, today=today)
|
||||
|
||||
|
||||
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
|
||||
# child-presence counterpart to ``regenerate_for_definition``. The
|
||||
# presence business layer that lands in Feature 09 MUST call
|
||||
# ``regenerate_for_child`` after EVERY presence-state write, so future
|
||||
# instances track a child's changed presence: set_presence_pattern
|
||||
# (including clearing/setting an empty pattern), deleting/clearing the
|
||||
# presence schedule (PresenceSchedulesDao.delete),
|
||||
# create_presence_override, and delete_presence_override. A schedule
|
||||
# delete changes presence state too (the child becomes always-present),
|
||||
# so it must regenerate just like the others. It is deliberately NOT
|
||||
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
|
||||
# business rules), and no presence business layer exists yet to host the
|
||||
# call — see the module docstring note above.
|
||||
async def regenerate_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> int:
|
||||
"""Regenerate a child's future instances after a presence change.
|
||||
|
||||
The Feature 09 presence services call this after every
|
||||
presence-state write — set_presence_pattern (including clearing or
|
||||
setting an empty pattern), deleting/clearing the presence schedule
|
||||
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||
create_presence_override, and delete_presence_override — so a
|
||||
child's future instances track its new presence: the child's open
|
||||
instances at or after today are deleted across ALL its definitions
|
||||
(never a completed instance, never the past) via
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
||||
then the materialization walk re-runs over the rolling horizon
|
||||
``[today, today + horizon_days]`` scoped to that child only,
|
||||
so the child's now-absent/present dates re-materialize against
|
||||
today's presence WITHOUT touching any other child's rows.
|
||||
|
||||
``child_id`` must be a plain int — bool and float are rejected
|
||||
(SQLite binds a bool as 0/1 and a float would round, so a malformed
|
||||
id must never mutate the wrong profile).
|
||||
|
||||
"today" is computed the same way the walk computes it —
|
||||
:func:`~.dao_instances._today` — and the re-materialization range uses the
|
||||
delete's returned effective cutoff (see
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
|
||||
so the delete cutoff and the re-materialization window share ONE
|
||||
execution-day anchor even when the call is queued across midnight.
|
||||
``today`` optionally pins a caller-resolved HA-local date, threaded
|
||||
into the delete cutoff and the re-materialization for the same
|
||||
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
|
||||
re-materialization window; when omitted it falls back to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
|
||||
raises ValueError BEFORE any delete. Returns the number of instances
|
||||
the re-materialization upserted for the child across the whole
|
||||
snapshot.
|
||||
"""
|
||||
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||
|
||||
horizon = _resolve_horizon_days(horizon_days)
|
||||
today_date = _resolve_today(today)
|
||||
|
||||
instances = QuestInstancesDao(database)
|
||||
# Use the delete's returned effective cutoff as the materialize anchor,
|
||||
# NOT the independently-computed "today" above. If the call is queued
|
||||
# across midnight the delete clamps its cutoff to the execution-day;
|
||||
# materializing from the stale "today" (now yesterday) would trip the
|
||||
# walk's no-past guard and leave the child's future instances deleted
|
||||
# but not rebuilt. One anchor for both keeps them consistent.
|
||||
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
|
||||
child_id, today_date.isoformat(), today=today
|
||||
)
|
||||
start = datetime.date.fromisoformat(effective_cutoff)
|
||||
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
|
||||
return await materialize(
|
||||
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
||||
)
|
||||
from .core.materialize import * # noqa: F401,F403
|
||||
|
||||
@@ -1,593 +1,11 @@
|
||||
"""Versioned schema migration runner for the NestQuest SQLite database.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package.
|
||||
|
||||
The runner owns one metadata table, ``nestquest_schema_version``, holding
|
||||
a single row with the schema version the database file currently
|
||||
reflects. Migrations are declared as an ordered list: entry ``n``
|
||||
upgrades version ``n-1`` to version ``n``. Applying a migration means
|
||||
running its DDL inside one transaction and stamping the new version in
|
||||
that same transaction, so a database never persists a half-applied
|
||||
migration: any failure rolls the statements and the version stamp back
|
||||
together, leaving the file at its prior version.
|
||||
|
||||
Version 0 means "the file exists but carries no NestQuest schema yet"
|
||||
— a fresh file, or a file this integration has never written. Starting
|
||||
the runner against version 0 applies migration 1, which applies the
|
||||
full v1 DDL from :mod:`.schema` and stamps version 1.
|
||||
|
||||
Migration 1 was rewritten pre-release (D-007): nothing was ever
|
||||
shipped, so the v1 DDL now creates the ``quest_*`` tables directly
|
||||
instead of the legacy ``task_*`` spellings, and no compatibility shim
|
||||
exists. Dev machines that already hold a version-1 database with
|
||||
``task_*`` tables are handled by migration 2, which detects the legacy
|
||||
table names and ``ALTER TABLE ... RENAME TO``s them to their current
|
||||
names before anything else assumes the new model. Migration 3 brings
|
||||
pre-D-008 databases to the multi-assignee model: it creates
|
||||
``quest_definition_assignees`` and, when ``quest_definitions`` still
|
||||
carries a single ``child_id`` column, rebuilds the table without it,
|
||||
copying each definition's assignee across. Migration 4 adds the
|
||||
windows table, migration 5 rebuilds ``quest_instances`` onto the
|
||||
widened (definition_id, child_id, due_date, window) key, and migration
|
||||
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
|
||||
column. Migration 7 adds the generic ``nestquest_meta_state``
|
||||
key/value table for integration-owned bookkeeping (Feature 11's
|
||||
missed-sweep watermark). Each callable migration runs inside its own
|
||||
transaction
|
||||
together with its version stamp, so a crash mid-step rolls the
|
||||
statements and the stamp back together.
|
||||
|
||||
Migration entries are either an ordered sequence of SQL statements or
|
||||
an async callable taking ``(database, target_version)``. Callables
|
||||
exist for steps whose SQL depends on the database's current state (a
|
||||
legacy rename only fires when a legacy table is present; a rebuild only
|
||||
when the old column shape is present) and own their transaction so they
|
||||
can bracket it with connection pragmas the runner's uniform transaction
|
||||
cannot express.
|
||||
|
||||
Each migration's statements and its version stamp run inside ONE
|
||||
transaction, and the stamp is written as DELETE + INSERT rather than
|
||||
UPDATE because a version-0 database has no version row to update yet.
|
||||
An UPDATE form would silently match zero rows there and leave the
|
||||
database migrated but unstamped — reporting version 0 forever, and
|
||||
re-applying migration 1 on every startup. The DELETE+INSERT shape is
|
||||
also what makes the stamp a replace, so the row never duplicates.
|
||||
|
||||
Idempotence across restarts: a database already at the latest version
|
||||
has no pending migrations, so the runner opens a read-only check, finds
|
||||
nothing to do, and leaves the file byte-untouched (no write transaction
|
||||
is opened, so no WAL churn).
|
||||
|
||||
Migration list policy: entries are append-only. Once shipped, a
|
||||
migration's SQL must never be edited — an old database applying it for
|
||||
the first time and a fresh database would then diverge. Fix-forward
|
||||
with a new migration instead. The version stamp lives in the same
|
||||
SQLite file as the data it describes, so restoring a backup restores
|
||||
its matching schema version with it.
|
||||
|
||||
Corrupt or missing files are not this module's concern: startup
|
||||
handling of those cases is task d5bca74b, which decides whether to
|
||||
open fresh or raise ConfigEntryNotReady. This runner assumes it is
|
||||
handed an open, working connection.
|
||||
The versioned migration runner lives in
|
||||
:mod:`custom_components.nestquest.core.migrations`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .migrations import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Union
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
from .schema import (
|
||||
COMPLETION_EVENTS_TABLE_SQL,
|
||||
QUEST_DEFINITIONS_TABLE_SQL,
|
||||
QUEST_INSTANCES_TABLE_SQL,
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
SCHEMA_V7_META_STATE_DDL,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: One migration entry: either an ordered sequence of SQL statements or
|
||||
#: an async callable taking ``(database, target_version)`` that manages
|
||||
#: its own transaction and stamps via :func:`stamp_schema_version`.
|
||||
MigrationStep = Union[
|
||||
Sequence[str],
|
||||
Callable[[NestQuestDatabase, int], Awaitable[None]],
|
||||
]
|
||||
|
||||
#: Metadata table holding the applied schema version. Prefixed with the
|
||||
#: integration domain so a config directory shared with other tools can
|
||||
#: never collide with a foreign ``schema_version`` table.
|
||||
VERSION_TABLE = "nestquest_schema_version"
|
||||
|
||||
#: DDL for the version metadata table. The singleton-row invariant is
|
||||
#: enforced by the schema itself: ``id`` is fixed at 1 by CHECK, keyed
|
||||
#: PRIMARY KEY, so the table can never hold a second row, and the
|
||||
#: stamp write is an UPDATE (no DELETE+INSERT needed). ``version`` is
|
||||
#: a positive integer: version 0 means "no row" and is never stored,
|
||||
#: so a negative or zero stored value is corruption, not a state the
|
||||
#: runner can act on.
|
||||
VERSION_TABLE_DDL = f"""
|
||||
CREATE TABLE IF NOT EXISTS {VERSION_TABLE} (
|
||||
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
|
||||
version INTEGER NOT NULL CHECK (version >= 1)
|
||||
)
|
||||
"""
|
||||
|
||||
#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades
|
||||
#: version ``n-1`` to version ``n``.
|
||||
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
|
||||
|
||||
#: The assignees table DDL, shared with migration 1 via the schema
|
||||
#: module so a rebuilt and a freshly created table cannot drift.
|
||||
_ASSIGNEES_TABLE_DDL: str = SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL[0]
|
||||
|
||||
#: Legacy (pre-D-007) table name -> current table name. Order matters:
|
||||
#: the parent table renames first so the child's foreign key reference
|
||||
#: is rewritten while its parent's new name is already in place.
|
||||
LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = (
|
||||
("task_definitions", "quest_definitions"),
|
||||
("task_instances", "quest_instances"),
|
||||
)
|
||||
|
||||
|
||||
async def stamp_schema_version(
|
||||
database: NestQuestDatabase, version: int
|
||||
) -> None:
|
||||
"""Write the schema version stamp (inside the caller's transaction).
|
||||
|
||||
INSERT keyed on the singleton row (id = 1) with an upsert: a fresh
|
||||
file has no row yet, so UPDATE alone would silently match zero rows
|
||||
there and leave a migrated-but-unstamped database.
|
||||
"""
|
||||
await database.execute(
|
||||
f"INSERT INTO {VERSION_TABLE} (id, version) "
|
||||
"VALUES (1, ?) "
|
||||
"ON CONFLICT (id) DO UPDATE SET version = excluded.version",
|
||||
(version,),
|
||||
)
|
||||
|
||||
|
||||
async def _table_has_column(
|
||||
database: NestQuestDatabase, table: str, column: str
|
||||
) -> bool:
|
||||
"""Return True when ``table`` currently carries ``column``."""
|
||||
rows = await database.fetch_all(f"PRAGMA table_info({table})")
|
||||
return any(row[1] == column for row in rows)
|
||||
|
||||
|
||||
#: Window assigned to instances migrated from the pre-window instance
|
||||
#: shape (D-008). Pre-window rows were generated for whole-day
|
||||
#: obligations; the morning window is the documented default landing
|
||||
#: spot, and future dates are regenerated with real window declarations
|
||||
#: by materialization (delete_future_uncompleted + re-run).
|
||||
LEGACY_MIGRATED_WINDOW = "morning"
|
||||
|
||||
|
||||
async def _rename_legacy_task_tables(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Rename any surviving ``task_*`` tables to their ``quest_*`` names.
|
||||
|
||||
Dev-machines-only path (D-007): a database stamped version 1 by the
|
||||
pre-rename runner holds ``task_definitions``/``task_instances``.
|
||||
Each legacy name still present is renamed; fresh databases find
|
||||
nothing and this step is a no-op. Modern SQLite rewrites foreign
|
||||
key clauses in other tables to follow the rename, so the renamed
|
||||
schema is indistinguishable from a freshly created one apart from
|
||||
internal autoindex names. Runs in its own transaction together
|
||||
with the version stamp, so a crash rolls both back.
|
||||
"""
|
||||
async with database.transaction():
|
||||
for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES:
|
||||
row = await database.fetch_one(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name = ?",
|
||||
(legacy_name,),
|
||||
)
|
||||
if row is not None:
|
||||
await database.execute(
|
||||
f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"'
|
||||
)
|
||||
LOGGER.info(
|
||||
"Renamed legacy table %s to %s",
|
||||
legacy_name,
|
||||
current_name,
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
|
||||
|
||||
async def _add_definition_assignees(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Bring definitions to the multi-assignee model (D-008).
|
||||
|
||||
Two shapes arrive here:
|
||||
|
||||
- Fresh or renamed databases whose ``quest_definitions`` already
|
||||
matches the current canonical DDL (no ``child_id`` column): only
|
||||
the assignees table needs creating (``IF NOT EXISTS`` is a no-op
|
||||
when migration 1 already made it).
|
||||
- Databases stamped at version 2 whose ``quest_definitions`` still
|
||||
carries the single ``child_id`` column: the table is rebuilt via
|
||||
SQLite's canonical procedure — create the new table, copy rows,
|
||||
move each definition's assignee into
|
||||
``quest_definition_assignees``, drop the old table, rename — so
|
||||
``DROP COLUMN``'s foreign-key restriction never applies and no
|
||||
definition or instance row is lost.
|
||||
|
||||
The copy runs with ``foreign_keys = OFF`` because dropping the old
|
||||
table under enforcement would fire the implicit DELETE and violate
|
||||
the ``quest_instances`` foreign key; ``PRAGMA foreign_key_check``
|
||||
runs inside the same transaction before the stamp, so a corrupt
|
||||
copy aborts the whole migration instead of persisting. The pragma
|
||||
is a connection-level no-op inside a transaction, hence the
|
||||
off/transaction/on bracket.
|
||||
"""
|
||||
legacy_child_id = await _table_has_column(
|
||||
database, "quest_definitions", "child_id"
|
||||
)
|
||||
await database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
async with database.transaction():
|
||||
await database.execute(_ASSIGNEES_TABLE_DDL)
|
||||
if legacy_child_id:
|
||||
await database.execute(
|
||||
"CREATE TABLE quest_definitions_rebuilt "
|
||||
f"{QUEST_DEFINITIONS_TABLE_SQL}"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT INTO quest_definitions_rebuilt "
|
||||
"(id, title, description, icon, schedule_rule_id, "
|
||||
"due_time, is_active, created_at) "
|
||||
"SELECT id, title, description, icon, "
|
||||
"schedule_rule_id, due_time, is_active, created_at "
|
||||
"FROM quest_definitions ORDER BY id"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT OR IGNORE INTO quest_definition_assignees "
|
||||
"(definition_id, child_id) "
|
||||
"SELECT id, child_id FROM quest_definitions"
|
||||
)
|
||||
await database.execute("DROP TABLE quest_definitions")
|
||||
await database.execute(
|
||||
"ALTER TABLE quest_definitions_rebuilt "
|
||||
"RENAME TO quest_definitions"
|
||||
)
|
||||
violations = await database.fetch_all(
|
||||
"PRAGMA foreign_key_check"
|
||||
)
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
"definition rebuild produced foreign-key "
|
||||
f"violations: {violations!r}"
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
finally:
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _widen_instance_key(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Bring ``quest_instances`` to the D-008 instance key.
|
||||
|
||||
Databases whose instance table still carries the pre-window shape
|
||||
(no ``window`` column, UNIQUE on (definition_id, due_date)) are
|
||||
rebuilt via SQLite's canonical procedure into the widened shape —
|
||||
UNIQUE (definition_id, child_id, due_date, window) — with every
|
||||
migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing
|
||||
ids (and the completion events referencing them) survive intact.
|
||||
The old rows were unique per (definition, due_date), so pinning one
|
||||
window cannot collide with the widened key. Fresh databases whose
|
||||
table already matches the canonical shape only stamp.
|
||||
|
||||
The rebuild runs with ``foreign_keys = OFF`` (dropping the old
|
||||
table under enforcement would fire the implicit DELETE and violate
|
||||
the ``completion_events`` foreign key) and verifies
|
||||
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||
stamp, so a corrupt copy aborts the whole migration.
|
||||
"""
|
||||
legacy_shape = not await _table_has_column(
|
||||
database, "quest_instances", "window"
|
||||
)
|
||||
await database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
async with database.transaction():
|
||||
if legacy_shape:
|
||||
await database.execute(
|
||||
"CREATE TABLE quest_instances_rebuilt "
|
||||
f"{QUEST_INSTANCES_TABLE_SQL}"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT INTO quest_instances_rebuilt "
|
||||
"(id, definition_id, child_id, window, due_date, "
|
||||
"due_time, generated_at) "
|
||||
"SELECT id, definition_id, child_id, ?, due_date, "
|
||||
"due_time, generated_at "
|
||||
"FROM quest_instances ORDER BY id",
|
||||
(LEGACY_MIGRATED_WINDOW,),
|
||||
)
|
||||
await database.execute("DROP TABLE quest_instances")
|
||||
await database.execute(
|
||||
"ALTER TABLE quest_instances_rebuilt "
|
||||
"RENAME TO quest_instances"
|
||||
)
|
||||
violations = await database.fetch_all(
|
||||
"PRAGMA foreign_key_check"
|
||||
)
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
"instance rebuild produced foreign-key "
|
||||
f"violations: {violations!r}"
|
||||
)
|
||||
LOGGER.info(
|
||||
"Widened quest_instances key; migrated rows pinned "
|
||||
"to the %r window",
|
||||
LEGACY_MIGRATED_WINDOW,
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
finally:
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _add_actor_child_id(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Bring ``completion_events`` to the D-008 actor-child shape.
|
||||
|
||||
The ``actor_child_id`` column records which panel profile was
|
||||
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
|
||||
because the canonical shape also carries an actor-pair coherence
|
||||
CHECK (panel events require it, user events forbid it), and CHECK
|
||||
constraints cannot be added to an existing table. Databases with
|
||||
the legacy shape are therefore rebuilt via SQLite's canonical
|
||||
procedure, backfilling each legacy panel row's ``actor_child_id``
|
||||
to ``child_id`` — pre-D-008 panel completions were always recorded
|
||||
against the tapped child themselves — while admin rows stay NULL.
|
||||
Row ids are preserved so ``quest_instances`` references and the
|
||||
append-only history stay intact. Fresh databases already match
|
||||
the canonical shape and only stamp.
|
||||
|
||||
The rebuild runs with ``foreign_keys = OFF`` and verifies
|
||||
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
|
||||
completion_events table keeps its append-only guarantee.
|
||||
"""
|
||||
legacy_shape = not await _table_has_column(
|
||||
database, "completion_events", "actor_child_id"
|
||||
)
|
||||
await database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
async with database.transaction():
|
||||
if legacy_shape:
|
||||
await database.execute(
|
||||
"CREATE TABLE completion_events_rebuilt "
|
||||
f"{COMPLETION_EVENTS_TABLE_SQL}"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT INTO completion_events_rebuilt "
|
||||
"(id, instance_id, child_id, event_type, actor_source, "
|
||||
"actor_user_id, actor_child_id, occurred_at, "
|
||||
"was_on_time) "
|
||||
"SELECT id, instance_id, child_id, event_type, "
|
||||
"actor_source, actor_user_id, "
|
||||
"CASE WHEN actor_source = 'panel' THEN child_id "
|
||||
"ELSE NULL END, "
|
||||
"occurred_at, was_on_time "
|
||||
"FROM completion_events ORDER BY id"
|
||||
)
|
||||
await database.execute("DROP TABLE completion_events")
|
||||
await database.execute(
|
||||
"ALTER TABLE completion_events_rebuilt "
|
||||
"RENAME TO completion_events"
|
||||
)
|
||||
violations = await database.fetch_all(
|
||||
"PRAGMA foreign_key_check"
|
||||
)
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
"completion_events rebuild produced foreign-key "
|
||||
f"violations: {violations!r}"
|
||||
)
|
||||
LOGGER.info(
|
||||
"Added actor_child_id to completion_events; legacy "
|
||||
"panel rows backfilled to their own child_id"
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
finally:
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
#: The ordered migration list. Append-only: never edit an applied
|
||||
#: entry, add the next one instead. (Migration 1's content was
|
||||
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
||||
MIGRATIONS: Sequence[MigrationStep] = [
|
||||
MIGRATION_1_V1_DDL,
|
||||
_rename_legacy_task_tables,
|
||||
_add_definition_assignees,
|
||||
# Migration 4 (D-008): the windows table is a plain new table — a
|
||||
# CREATE IF NOT EXISTS suffices for both fresh databases (which
|
||||
# already made it in migration 1) and pre-window databases.
|
||||
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
|
||||
# Migration 5 (D-008): rebuild quest_instances onto the widened
|
||||
# (definition_id, child_id, due_date, window) key.
|
||||
_widen_instance_key,
|
||||
# Migration 6 (D-008): rebuild completion_events with the
|
||||
# actor_child_id column and its actor-pair coherence CHECK,
|
||||
# backfilling legacy panel rows to their own child_id.
|
||||
_add_actor_child_id,
|
||||
# Migration 7 (Feature 11): the generic meta_state key/value table
|
||||
# — a plain new table, so CREATE IF NOT EXISTS suffices for both
|
||||
# fresh databases (which already made it in migration 1) and
|
||||
# pre-v7 databases. Strictly additive; no existing table changes.
|
||||
list(SCHEMA_V7_META_STATE_DDL),
|
||||
]
|
||||
|
||||
|
||||
async def _create_version_table(database: NestQuestDatabase) -> None:
|
||||
"""Create the version metadata table if it does not exist.
|
||||
|
||||
The table is created before any version check: it is the runner's
|
||||
own bookkeeping, and creating it is idempotent. The DDL enforces
|
||||
the singleton-row invariant (``id`` fixed at 1) and a positive
|
||||
integer version at the database level.
|
||||
"""
|
||||
await database.execute(VERSION_TABLE_DDL)
|
||||
|
||||
|
||||
async def read_schema_version(database: NestQuestDatabase) -> int:
|
||||
"""Return the schema version recorded in the database.
|
||||
|
||||
Returns 0 when the version table or its row is missing — the
|
||||
"fresh file" case. A stored version higher than the runner knows
|
||||
is surfaced as-is; the caller decides how to treat a future version.
|
||||
|
||||
Reading is deliberately passive: a missing table raises, it is not
|
||||
swallowed into 0, because :func:`apply_migrations` always creates
|
||||
the table before reading and a direct reader hitting "no such
|
||||
table" is a caller bug worth surfacing, not a fresh file.
|
||||
"""
|
||||
row = await database.fetch_one(
|
||||
f"SELECT version FROM {VERSION_TABLE} LIMIT 1"
|
||||
)
|
||||
if row is None:
|
||||
return 0
|
||||
version = row[0]
|
||||
if not isinstance(version, int) or isinstance(version, bool):
|
||||
raise RuntimeError(
|
||||
f"Schema version metadata is not an integer: {version!r}"
|
||||
)
|
||||
if version < 1:
|
||||
raise RuntimeError(
|
||||
f"Schema version metadata is invalid (must be >= 1): {version!r}"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
#: One asyncio.Lock per connection wrapper, created lazily and bound to
|
||||
#: the running loop, serializing whole apply_migrations runs so two
|
||||
#: concurrent callers on one connection queue instead of colliding on
|
||||
#: the wrapper's single-transaction rule.
|
||||
_MIGRATION_LOCKS: dict[int, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
|
||||
"""Return the migration lock bound to ``database``'s running loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _MIGRATION_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_MIGRATION_LOCKS[key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
async def apply_migrations(
|
||||
database: NestQuestDatabase,
|
||||
migrations: Sequence[MigrationStep] = MIGRATIONS,
|
||||
) -> int:
|
||||
"""Bring the database schema up to the latest version.
|
||||
|
||||
Reads the current version, applies every pending migration in order
|
||||
— each inside one transaction that also stamps the new version —
|
||||
and returns the resulting version. Applying zero pending
|
||||
migrations (database already current) is a no-op that logs at debug
|
||||
level and opens no write transaction.
|
||||
|
||||
The version is re-read inside each migration transaction before
|
||||
deciding to apply it: the outer read is advisory only (loop bounds,
|
||||
no-op fast path), and on one shared connection two interleaved
|
||||
runners would otherwise both read the same version and both apply
|
||||
a non-idempotent migration. Under the transaction's lock the
|
||||
second runner sees the first runner's stamp and skips.
|
||||
|
||||
Runners on one connection are serialized by an asyncio lock held
|
||||
for the whole decide-and-apply span: NestQuestDatabase rejects a
|
||||
second transaction while one is active (RuntimeError), so
|
||||
concurrent callers must queue, and the lock makes that queueing
|
||||
explicit. The loser of the race re-reads the version once it
|
||||
acquires the lock, sees the winner's stamp, and applies nothing.
|
||||
|
||||
Raises whatever the failing migration raised, with the database
|
||||
rolled back to its prior version and prior rows untouched.
|
||||
"""
|
||||
async with _migration_lock(database):
|
||||
await _create_version_table(database)
|
||||
current = await read_schema_version(database)
|
||||
latest = len(migrations)
|
||||
|
||||
if current > latest:
|
||||
raise RuntimeError(
|
||||
f"Database schema version {current} is newer than this "
|
||||
f"integration understands (latest known: {latest}); "
|
||||
"downgrades are not supported"
|
||||
)
|
||||
|
||||
if current == latest:
|
||||
LOGGER.debug(
|
||||
"NestQuest schema already at version %d; "
|
||||
"no migrations to apply",
|
||||
current,
|
||||
)
|
||||
return current
|
||||
|
||||
for target in range(current + 1, latest + 1):
|
||||
applied = False
|
||||
step = migrations[target - 1]
|
||||
if callable(step):
|
||||
# Callable migrations own their transaction: some steps
|
||||
# (table rebuilds) must run connection pragmas such as
|
||||
# ``foreign_keys = OFF`` BEFORE opening one, which the
|
||||
# runner's uniform transaction cannot express, and they
|
||||
# stamp inside their own transaction so a failure rolls
|
||||
# the statements and the version stamp back together.
|
||||
# The version is re-read here under the migration lock:
|
||||
# runners on one connection are serialized by it, so
|
||||
# the version cannot change between this read and the
|
||||
# callable's commit.
|
||||
in_tx_version = await read_schema_version(database)
|
||||
if in_tx_version > latest:
|
||||
raise RuntimeError(
|
||||
f"Database schema version {in_tx_version} is newer "
|
||||
f"than this integration understands (latest known: "
|
||||
f"{latest}); downgrades are not supported"
|
||||
)
|
||||
if in_tx_version < target:
|
||||
await step(database, target)
|
||||
applied = True
|
||||
else:
|
||||
async with database.transaction():
|
||||
# Re-read the version INSIDE the write transaction:
|
||||
# the outer read was advisory, and the wrapper
|
||||
# serializes individual statements, not the
|
||||
# read-decide-apply decision. Under the
|
||||
# transaction's lock the version cannot change
|
||||
# between the read and the stamp, and the loser of
|
||||
# an interleaved race (a queued second caller
|
||||
# re-running apply_migrations after the winner
|
||||
# already migrated) re-reads here, sees the
|
||||
# winner's stamp, and skips instead of re-applying
|
||||
# a non-idempotent migration with a stale version.
|
||||
in_tx_version = await read_schema_version(database)
|
||||
if in_tx_version > latest:
|
||||
raise RuntimeError(
|
||||
f"Database schema version {in_tx_version} is "
|
||||
f"newer than this integration understands "
|
||||
f"(latest known: {latest}); downgrades are "
|
||||
f"not supported"
|
||||
)
|
||||
if in_tx_version < target:
|
||||
for sql in step:
|
||||
await database.execute(sql)
|
||||
await stamp_schema_version(database, target)
|
||||
applied = True
|
||||
applied_statements = len(step)
|
||||
if applied:
|
||||
LOGGER.info(
|
||||
"NestQuest schema migrated to version %d",
|
||||
target,
|
||||
)
|
||||
|
||||
return latest
|
||||
from .core.migrations import * # noqa: F401,F403
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Options flow for NestQuest post-setup settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -33,18 +32,17 @@ from .const import (
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||
|
||||
MIN_HORIZON_DAYS = 1
|
||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
||||
|
||||
_TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
||||
|
||||
|
||||
def _parse_time(value: str) -> bool:
|
||||
"""Return True when value is a valid 24-hour HH:MM string."""
|
||||
return _TIME_PATTERN.fullmatch(value) is not None
|
||||
return TIME_PATTERN.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
|
||||
@@ -1,477 +1,11 @@
|
||||
"""The presence and custody model (Feature 05).
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.presence` for the integration package.
|
||||
|
||||
PURE module: no Home Assistant imports, no database access, no
|
||||
timezone logic — it operates on plain calendar dates only (feature
|
||||
guardrail). The later engine (cycle position, ``is_present``,
|
||||
overrides, previews) builds directly on this model.
|
||||
|
||||
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
|
||||
week is this child at this house?" for an N-week repeating custody
|
||||
pattern. Two of the household's three children follow a two-week
|
||||
rotation; a child with NO schedule row is present every day (that
|
||||
default lives in the engine, not here).
|
||||
|
||||
Pattern encoding: the model's canonical serialization is the SAME
|
||||
pipe-separated CSV the database stores (see schema.py's
|
||||
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
|
||||
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
|
||||
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
|
||||
empty segment means absent every day of that week. Encoding sorts
|
||||
each segment's weekdays and uses the exact segment order, so
|
||||
``decode(encode(schedule))`` round-trips losslessly and two schedules
|
||||
with equal patterns produce equal strings.
|
||||
|
||||
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
|
||||
computed from anchor-date arithmetic ONLY — never ISO week numbers or
|
||||
week parity (D-004): 53-week years silently invert parity on January
|
||||
1st. The arithmetic lives in the engine task; this module validates
|
||||
that the anchor is a strict calendar date.
|
||||
|
||||
Validation policy: construction is total — every reject happens in
|
||||
``__init__`` so no invalid schedule can ever exist:
|
||||
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
|
||||
the database CHECK (schema.py) and keeps anchor arithmetic and the
|
||||
encoded pattern human-readable; revisit both together if a longer
|
||||
cycle is ever needed.
|
||||
- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly
|
||||
(no gaps, no extra weeks) with sets of integers 0..6 (bools
|
||||
rejected: True would silently mean Monday).
|
||||
- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a
|
||||
``datetime.date``).
|
||||
The presence and custody model lives in
|
||||
:mod:`custom_components.nestquest.core.presence` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .presence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
|
||||
#: The schema's documented cap for cycle_length_weeks (schema.py keeps
|
||||
#: the pattern human-readable and the anchor arithmetic small).
|
||||
MAX_CYCLE_LENGTH_WEEKS = 4
|
||||
|
||||
_DATE_FORMAT = "%Y-%m-%d"
|
||||
|
||||
|
||||
def _parse_date(value: object, field_name: str) -> datetime.date:
|
||||
"""Accept a datetime.date or a strict YYYY-MM-DD string.
|
||||
|
||||
``datetime.datetime`` is rejected explicitly: it SUBCLASSES
|
||||
datetime.date, so a bare isinstance check would store a value with
|
||||
a time component and every later calendar-date comparison (e.g.
|
||||
``PresenceOverride.covers``) would raise TypeError.
|
||||
"""
|
||||
if isinstance(value, datetime.datetime):
|
||||
raise ValueError(
|
||||
f"{field_name} must be a plain calendar date, not a "
|
||||
f"datetime with a time component: {value!r}"
|
||||
)
|
||||
if isinstance(value, datetime.date):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"{field_name} must be a datetime.date or an ISO date string, "
|
||||
f"got {value!r}"
|
||||
)
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() != value:
|
||||
raise ValueError(
|
||||
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _validate_week_index(week: object, cycle_length_weeks: int) -> int:
|
||||
"""Reject non-int week indices (bools included) and out-of-cycle."""
|
||||
if isinstance(week, bool) or not isinstance(week, int):
|
||||
raise ValueError(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
)
|
||||
if not 0 <= week < cycle_length_weeks:
|
||||
raise ValueError(
|
||||
f"pattern week index must be in 0..{cycle_length_weeks - 1}, "
|
||||
f"got {week}"
|
||||
)
|
||||
return week
|
||||
|
||||
|
||||
def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
|
||||
"""Validate one week's present-weekday set."""
|
||||
if isinstance(weekdays, (str, bytes)) or weekdays is None:
|
||||
raise ValueError(
|
||||
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
|
||||
f"got {weekdays!r}"
|
||||
)
|
||||
try:
|
||||
members = list(weekdays)
|
||||
except TypeError:
|
||||
raise ValueError(
|
||||
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
|
||||
f"got {weekdays!r}"
|
||||
) from None
|
||||
for member in members:
|
||||
if isinstance(member, bool) or not isinstance(member, int):
|
||||
raise ValueError(
|
||||
f"pattern[{week}] weekday must be an int 0..6, got {member!r}"
|
||||
)
|
||||
if not 0 <= member <= 6:
|
||||
raise ValueError(
|
||||
f"pattern[{week}] weekday must be in 0..6 (Monday=0), "
|
||||
f"got {member}"
|
||||
)
|
||||
return frozenset(members)
|
||||
|
||||
|
||||
def _validate_cycle_length(value: object) -> int:
|
||||
"""Reject non-int and out-of-range cycle lengths (bools included)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"cycle_length_weeks must be an integer, got {value!r}"
|
||||
)
|
||||
if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS:
|
||||
raise ValueError(
|
||||
f"cycle_length_weeks must be between 1 and "
|
||||
f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools would silently address child 1)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class PresenceSchedule:
|
||||
"""One child's N-week repeating presence pattern.
|
||||
|
||||
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
|
||||
to the set of present weekdays (ints, Monday=0). A week with an
|
||||
empty set means the child is absent every day of that week — which
|
||||
is distinct from having no schedule at all (the engine treats a
|
||||
missing schedule as present every day).
|
||||
"""
|
||||
|
||||
child_id: int
|
||||
cycle_length_weeks: int
|
||||
anchor_date: datetime.date
|
||||
pattern: MappingProxyType # week index -> frozenset of weekdays
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
child_id: int,
|
||||
cycle_length_weeks: int,
|
||||
anchor_date: object,
|
||||
pattern: dict[int, object],
|
||||
) -> None:
|
||||
object.__setattr__(self, "child_id", _validate_child_id(child_id))
|
||||
cycle = _validate_cycle_length(cycle_length_weeks)
|
||||
object.__setattr__(self, "cycle_length_weeks", cycle)
|
||||
object.__setattr__(
|
||||
self, "anchor_date", _parse_date(anchor_date, "anchor_date")
|
||||
)
|
||||
if not isinstance(pattern, dict):
|
||||
raise ValueError(
|
||||
"pattern must map week index to a set of present "
|
||||
f"weekdays, got {pattern!r}"
|
||||
)
|
||||
normalised: dict[int, frozenset[int]] = {}
|
||||
for week, weekdays in pattern.items():
|
||||
index = _validate_week_index(week, cycle)
|
||||
normalised[index] = _validate_weekday_set(index, weekdays)
|
||||
missing = [
|
||||
week for week in range(cycle) if week not in normalised
|
||||
]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"pattern must cover week indices 0..{cycle - 1}; "
|
||||
f"missing {missing}"
|
||||
)
|
||||
# Frozen must mean frozen: a bare dict would let callers inject
|
||||
# unvalidated weekdays (or drop required weeks) AFTER
|
||||
# construction, bypassing total validation. The mapping
|
||||
# proxy exposes the validated contents read-only; the
|
||||
# per-week sets are already frozensets.
|
||||
object.__setattr__(self, "pattern", MappingProxyType(normalised))
|
||||
|
||||
def encode(self) -> str:
|
||||
"""Serialize to the database's pipe-separated CSV shape.
|
||||
|
||||
Segments appear in week order; each segment is the week's
|
||||
present weekdays as a sorted comma-separated list (an empty set
|
||||
encodes as an empty segment). Two schedules with equal
|
||||
patterns encode identically.
|
||||
"""
|
||||
segments = [
|
||||
",".join(str(day) for day in sorted(self.pattern[week]))
|
||||
for week in range(self.cycle_length_weeks)
|
||||
]
|
||||
return "|".join(segments)
|
||||
|
||||
@classmethod
|
||||
def decode(
|
||||
cls,
|
||||
child_id: int,
|
||||
anchor_date: object,
|
||||
encoded: str,
|
||||
) -> "PresenceSchedule":
|
||||
"""Build a schedule from its encoded pattern.
|
||||
|
||||
The cycle length is derived from the segment count, mirroring
|
||||
the database's segment-count CHECK. Decoding is strict: wrong
|
||||
shapes, non-numeric or out-of-range weekdays are rejected —
|
||||
the database CHECKs make such rows unrepresentable, and this
|
||||
is the same contract on the model side. A segment count above
|
||||
the documented cycle cap is rejected by the ordinary
|
||||
construction validation.
|
||||
"""
|
||||
if not isinstance(encoded, str):
|
||||
raise ValueError(
|
||||
f"encoded pattern must be a string, got {encoded!r}"
|
||||
)
|
||||
segments = encoded.split("|")
|
||||
pattern: dict[int, set[int]] = {}
|
||||
for week, segment in enumerate(segments):
|
||||
weekdays: set[int] = set()
|
||||
if segment:
|
||||
for part in segment.split(","):
|
||||
if len(part) != 1 or part not in "0123456":
|
||||
raise ValueError(
|
||||
f"encoded segment {week} carries an invalid "
|
||||
f"weekday {part!r} (single digits 0-6 only)"
|
||||
)
|
||||
weekdays.add(int(part))
|
||||
pattern[week] = weekdays
|
||||
return cls(child_id, len(segments), anchor_date, pattern)
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class PresenceOverride:
|
||||
"""A date-specific present/absent flag beating any pattern.
|
||||
|
||||
Covers a single date (``end_date == start_date``) or an inclusive
|
||||
range. A concrete range is the storage shape
|
||||
(``presence_overrides.end_date`` is NOT NULL), so the model
|
||||
requires both bounds and validates start <= end. Construction and
|
||||
validation live here so the engine can trust every instance.
|
||||
"""
|
||||
|
||||
child_id: int
|
||||
start_date: datetime.date
|
||||
end_date: datetime.date
|
||||
is_present: bool
|
||||
note: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
child_id: int,
|
||||
start_date: object,
|
||||
end_date: object,
|
||||
is_present: bool,
|
||||
note: str | None = None,
|
||||
) -> None:
|
||||
object.__setattr__(self, "child_id", _validate_child_id(child_id))
|
||||
start = _parse_date(start_date, "start_date")
|
||||
end = _parse_date(end_date, "end_date")
|
||||
if end < start:
|
||||
raise ValueError(
|
||||
f"end_date must be on or after start_date, got "
|
||||
f"{end.isoformat()} < {start.isoformat()}"
|
||||
)
|
||||
object.__setattr__(self, "start_date", start)
|
||||
object.__setattr__(self, "end_date", end)
|
||||
if not isinstance(is_present, bool):
|
||||
raise ValueError(
|
||||
f"is_present must be a real bool, got {is_present!r}"
|
||||
)
|
||||
object.__setattr__(self, "is_present", is_present)
|
||||
if note is not None and not isinstance(note, str):
|
||||
raise ValueError(f"note must be a string or None, got {note!r}")
|
||||
object.__setattr__(self, "note", note)
|
||||
|
||||
def covers(self, date: datetime.date) -> bool:
|
||||
"""Return True when the override applies to ``date``."""
|
||||
return self.start_date <= date <= self.end_date
|
||||
|
||||
|
||||
def cycle_week_index(
|
||||
schedule: PresenceSchedule, target_date: object
|
||||
) -> int:
|
||||
"""Return the cycle week index ``target_date`` falls in.
|
||||
|
||||
Anchor-date arithmetic ONLY (D-004): ``((target_date -
|
||||
anchor_date).days // 7) % cycle_length_weeks``. Python's floor
|
||||
division makes dates BEFORE the anchor behave correctly — they
|
||||
walk the cycle backwards (day -1 is the last week of the cycle,
|
||||
day -8 wraps one further) instead of inverting, which is exactly
|
||||
what ISO week parity does wrong in 53-week years. The modulo of
|
||||
a negative in Python is non-negative, so the index is always a
|
||||
valid week index.
|
||||
"""
|
||||
target = _parse_date(target_date, "target_date")
|
||||
days = (target - schedule.anchor_date).days
|
||||
return (days // 7) % schedule.cycle_length_weeks
|
||||
|
||||
|
||||
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
|
||||
#: years of daily checks. A child absent every day (an all-absent
|
||||
#: pattern with a blocking override) would otherwise loop forever; the
|
||||
#: cap bounds the scan and surfaces the situation as an error instead.
|
||||
MAX_PREVIEW_SCAN_DAYS = 366 * 2
|
||||
|
||||
|
||||
class PresenceEngine:
|
||||
"""Answers "is this child at this house on this date?".
|
||||
|
||||
Pure and side-effect-free: built from an immutable snapshot of the
|
||||
children's schedules (``child_id -> PresenceSchedule``), it never
|
||||
touches the database — callers build a snapshot from the DAO layer
|
||||
and pass it in. A child with NO schedule entry is present every
|
||||
day (the third household child's case); a child WITH a schedule is
|
||||
present exactly when the date's weekday is in that schedule's
|
||||
pattern for the date's cycle week index (anchor-date arithmetic —
|
||||
:func:`cycle_week_index`, never ISO week parity).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schedules: dict[int, PresenceSchedule],
|
||||
overrides: dict[int, list[PresenceOverride]] | None = None,
|
||||
) -> None:
|
||||
if not isinstance(schedules, dict):
|
||||
raise ValueError(
|
||||
f"schedules must map child_id to PresenceSchedule, got "
|
||||
f"{schedules!r}"
|
||||
)
|
||||
for child_id, schedule in schedules.items():
|
||||
# Validate the KEY as a model child id too: True and 1.0
|
||||
# compare equal to 1, so {True: schedule(1)} would be
|
||||
# accepted and then behave differently on lookup.
|
||||
_validate_child_id(child_id)
|
||||
if not isinstance(schedule, PresenceSchedule):
|
||||
raise ValueError(
|
||||
f"schedules[{child_id!r}] must be a PresenceSchedule, "
|
||||
f"got {schedule!r}"
|
||||
)
|
||||
if schedule.child_id != child_id:
|
||||
raise ValueError(
|
||||
f"schedules is keyed {child_id!r} but holds the "
|
||||
f"schedule of child {schedule.child_id}"
|
||||
)
|
||||
if overrides is None:
|
||||
overrides = {}
|
||||
if not isinstance(overrides, dict):
|
||||
raise ValueError(
|
||||
"overrides must map child_id to a list of "
|
||||
f"PresenceOverride, got {overrides!r}"
|
||||
)
|
||||
for child_id, entries in overrides.items():
|
||||
_validate_child_id(child_id)
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError(
|
||||
f"overrides[{child_id!r}] must be a list of "
|
||||
f"PresenceOverride, got {entries!r}"
|
||||
)
|
||||
for entry in entries:
|
||||
if not isinstance(entry, PresenceOverride):
|
||||
raise ValueError(
|
||||
f"overrides[{child_id!r}] entries must be "
|
||||
f"PresenceOverride, got {entry!r}"
|
||||
)
|
||||
if entry.child_id != child_id:
|
||||
raise ValueError(
|
||||
f"overrides is keyed {child_id!r} but holds an "
|
||||
f"override of child {entry.child_id}"
|
||||
)
|
||||
# Read-only snapshot: reassignment is refused and no mutator
|
||||
# exists, so a built engine answers consistently for its
|
||||
# lifetime. The overrides lists are copied to immutable
|
||||
# tuples — a shallow mapping copy alone would leave caller-
|
||||
# owned lists reachable through _overrides, and mutating one
|
||||
# later would change is_present results.
|
||||
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"_overrides",
|
||||
MappingProxyType(
|
||||
{child_id: tuple(entries) for child_id, entries in overrides.items()}
|
||||
),
|
||||
)
|
||||
|
||||
def is_present(self, child_id: object, date: object) -> bool:
|
||||
"""Return whether ``child_id`` is present on ``date``.
|
||||
|
||||
Overrides take precedence over the repeating pattern: when an
|
||||
override covers the date, its flag IS the answer — including
|
||||
for a child with no schedule at all. The DAO layer guarantees
|
||||
a child's overrides never overlap; if a snapshot still carries
|
||||
two covering the same date, the one with the LATEST start date
|
||||
wins (deterministic, and matches "the most specific swap wins").
|
||||
"""
|
||||
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||
target = _parse_date(date, "date")
|
||||
entries = self._overrides.get(child_id) or []
|
||||
covering = [
|
||||
entry for entry in entries if entry.covers(target)
|
||||
]
|
||||
if covering:
|
||||
winner = max(covering, key=lambda entry: entry.start_date)
|
||||
return winner.is_present
|
||||
schedule = self._schedules.get(child_id)
|
||||
if schedule is None:
|
||||
# No schedule at all: present every day (household default).
|
||||
return True
|
||||
week = cycle_week_index(schedule, target)
|
||||
return target.weekday() in schedule.pattern[week]
|
||||
|
||||
def next_present_dates(
|
||||
self,
|
||||
child_id: object,
|
||||
start_date: object,
|
||||
count: int,
|
||||
) -> list[datetime.date]:
|
||||
"""Return the next ``count`` dates the child is present.
|
||||
|
||||
Walks forward from ``start_date`` (INCLUSIVE — a parent
|
||||
previewing "starting Monday" expects Monday itself when the
|
||||
child is present) checking :meth:`is_present` for every day,
|
||||
so overrides and the pattern are honoured identically. The
|
||||
admin Schedule tab renders this preview so a parent can confirm
|
||||
a custody pattern is right before saving it.
|
||||
|
||||
A child absent every scanned day raises ValueError after the
|
||||
two-year search cap — an always-absent child would otherwise
|
||||
loop forever. ``count`` must be a positive integer.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
start = _parse_date(start_date, "start_date")
|
||||
if isinstance(count, bool) or not isinstance(count, int):
|
||||
raise ValueError(f"count must be an integer, got {count!r}")
|
||||
if count < 1:
|
||||
raise ValueError(f"count must be at least 1, got {count}")
|
||||
found: list[datetime.date] = []
|
||||
day = start
|
||||
for _ in range(MAX_PREVIEW_SCAN_DAYS):
|
||||
if self.is_present(child_id, day):
|
||||
found.append(day)
|
||||
if len(found) == count:
|
||||
return found
|
||||
day += datetime.timedelta(days=1)
|
||||
raise ValueError(
|
||||
f"child {child_id} is not present on any of the "
|
||||
f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; "
|
||||
f"{count} present day(s) cannot be previewed"
|
||||
)
|
||||
|
||||
def __setattr__(self, name: object, value: object) -> None:
|
||||
raise AttributeError(
|
||||
"PresenceEngine is an immutable snapshot; build a new engine "
|
||||
"instead of mutating this one"
|
||||
)
|
||||
from .core.presence import * # noqa: F401,F403
|
||||
|
||||
@@ -1,603 +1,11 @@
|
||||
"""Business layer for creating and editing quest definitions (Feature 06).
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.quest_definitions` for the integration package.
|
||||
|
||||
Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
|
||||
all-or-nothing create/edit here, and never touch the DAO SQL directly.
|
||||
:func:`create_quest_definition` is the create path,
|
||||
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
|
||||
:func:`unassign_child` the assignment paths, and
|
||||
:func:`set_quest_definition_active` the deactivate/reactivate path.
|
||||
There is no permission check here (that is Feature 09).
|
||||
|
||||
Validation policy mirrors :mod:`.children`:
|
||||
|
||||
- ``title`` is required and trimmed; empty or whitespace-only raises
|
||||
ValueError.
|
||||
- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
|
||||
(its constructor is total — rule validation is NOT re-implemented
|
||||
here).
|
||||
- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``);
|
||||
every id is a plain int (bools and floats rejected, since SQLite would
|
||||
bind them onto a real child) and must reference an existing ACTIVE
|
||||
child (the DAO enforces the active/exists check inside the same
|
||||
transaction). Duplicate ids are rejected here, before the composite
|
||||
primary key could surface them as an :class:`sqlite3.IntegrityError`.
|
||||
- ``windows`` must be a non-empty list (strictly a ``list``); each entry
|
||||
is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare
|
||||
name (no due time) or a ``(name, due_time)`` pair whose due time is a
|
||||
strict 24-hour HH:MM string. Duplicate window names are rejected
|
||||
here, before the composite primary key could surface them as an
|
||||
:class:`sqlite3.IntegrityError`.
|
||||
|
||||
The rule, definition, assignees and windows are persisted in ONE
|
||||
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
|
||||
so a rejected or failed create persists nothing. The definition-level
|
||||
``due_time`` column is superseded by per-window due times (D-008) and is
|
||||
never set here. ``created_at`` is stamped UTC ISO-8601 at second
|
||||
precision, matching :mod:`.children`.
|
||||
The quest definitions business layer lives in
|
||||
:mod:`custom_components.nestquest.core.quest_definitions` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .quest_definitions import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import ChildRecord, _connection_lock
|
||||
from .dao_rules import (
|
||||
QuestDefinitionRecord,
|
||||
QuestDefinitionSnapshot,
|
||||
QuestDefinitionWindowRecord,
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRuleStorage,
|
||||
ScheduleRulesDao,
|
||||
_UNSET,
|
||||
_validate_date,
|
||||
_validate_time,
|
||||
_validate_window,
|
||||
schedule_rule_from_storage,
|
||||
schedule_rule_storage_from_record,
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import regenerate_for_definition
|
||||
from .recurrence import ScheduleRule, occurs_on
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreatedQuestDefinition:
|
||||
"""The persisted result of a create, edit or activation change.
|
||||
|
||||
``definition`` is the stored row; ``rule`` is the schedule rule
|
||||
decoded back through the storage mapping; ``assignees`` and
|
||||
``windows`` are the definition's linked child records and window
|
||||
declarations, read through the DAO.
|
||||
"""
|
||||
|
||||
definition: QuestDefinitionRecord
|
||||
rule: ScheduleRule
|
||||
assignees: list[ChildRecord]
|
||||
windows: list[QuestDefinitionWindowRecord]
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_text(
|
||||
value: object, field: str, *, required: bool = False
|
||||
) -> str | None:
|
||||
"""Validate a free-text field and return it trimmed.
|
||||
|
||||
``None`` raises when ``required`` and otherwise returns None. A
|
||||
non-string value is rejected; an empty result is rejected for
|
||||
required fields.
|
||||
"""
|
||||
if value is None:
|
||||
if required:
|
||||
raise ValueError(f"{field} is required")
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||
trimmed = value.strip()
|
||||
if required and not trimmed:
|
||||
raise ValueError(f"{field} is required")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _validate_assignee_ids(value: object) -> list[int]:
|
||||
"""Return the assignee ids as plain ints, or raise naming the field.
|
||||
|
||||
The argument must be a non-empty list — strictly a ``list``: tuples
|
||||
and other sequences are rejected. Each entry must be a real int
|
||||
(bools and floats rejected) so SQLite cannot bind ``True`` onto
|
||||
child 1 or a float onto a neighbouring profile.
|
||||
"""
|
||||
if type(value) is not list:
|
||||
raise ValueError(
|
||||
f"assignee_child_ids must be a list, got {type(value).__name__}"
|
||||
)
|
||||
if not value:
|
||||
raise ValueError("assignee_child_ids must not be empty")
|
||||
ids: list[int] = []
|
||||
for entry in value:
|
||||
if isinstance(entry, bool) or not isinstance(entry, int):
|
||||
raise ValueError(
|
||||
"assignee_child_ids must contain integer child ids, "
|
||||
f"got {entry!r}"
|
||||
)
|
||||
ids.append(entry)
|
||||
if len(set(ids)) != len(ids):
|
||||
raise ValueError(
|
||||
"assignee_child_ids must not contain duplicate child ids"
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
def _normalize_windows(
|
||||
value: object,
|
||||
) -> list[tuple[str, str | None]]:
|
||||
"""Normalize the windows argument to ``(name, due_time)`` pairs.
|
||||
|
||||
The outer argument must be a non-empty list — strictly a ``list``:
|
||||
tuples and other sequences are rejected, while each individual entry
|
||||
may still be a ``(window, due_time)`` tuple. A bare window name
|
||||
(due time None) is also accepted. The name must be a
|
||||
:data:`~.const.QUEST_WINDOWS` spelling and a present due time must be
|
||||
a strict 24-hour HH:MM, both validated up front so a malformed
|
||||
window is rejected before any write.
|
||||
"""
|
||||
if type(value) is not list:
|
||||
raise ValueError(
|
||||
f"windows must be a list, got {type(value).__name__}"
|
||||
)
|
||||
if not value:
|
||||
raise ValueError("windows must not be empty")
|
||||
normalized: list[tuple[str, str | None]] = []
|
||||
seen: set[str] = set()
|
||||
for index, entry in enumerate(value):
|
||||
if isinstance(entry, str):
|
||||
name: object = entry
|
||||
due_time: object = None
|
||||
elif isinstance(entry, (tuple, list)) and len(entry) == 2:
|
||||
name, due_time = entry[0], entry[1]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"windows[{index}] must be a window name or a "
|
||||
f"(name, due_time) pair, got {entry!r}"
|
||||
)
|
||||
_validate_window(name)
|
||||
if due_time is not None:
|
||||
_validate_time(due_time, "due_time")
|
||||
if name in seen:
|
||||
raise ValueError(
|
||||
f"windows must not contain duplicate window {name!r}"
|
||||
)
|
||||
seen.add(name)
|
||||
normalized.append((name, due_time))
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_definition_id(value: object) -> int:
|
||||
"""Reject non-int definition ids (bools included) before any lookup.
|
||||
|
||||
SQLite binds Python bools as integers, so ``True`` would silently
|
||||
address definition 1 and a float would round — malformed service
|
||||
input must never mutate the wrong definition.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"definition_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools included) before any lookup.
|
||||
|
||||
SQLite binds Python bools as integers, so ``True`` would silently
|
||||
address child 1 and a float would round — malformed service input
|
||||
must never mutate the wrong profile.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def create_quest_definition(
|
||||
database: NestQuestDatabase,
|
||||
title: str,
|
||||
rule: ScheduleRule,
|
||||
assignee_child_ids: list[int],
|
||||
windows: list[str | tuple[str, str | None]],
|
||||
*,
|
||||
description: str | None = None,
|
||||
icon: str | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Create a quest definition (rule + assignees + windows) atomically.
|
||||
|
||||
Validates the arguments up front, then persists the schedule rule,
|
||||
the definition, its assignees and its windows in one transaction via
|
||||
the DAO. Returns the stored definition together with its decoded
|
||||
rule, assignees and windows — all read back inside that same
|
||||
transaction, so the returned bundle is a consistent creation
|
||||
snapshot. Raises ValueError on any rejected argument; a rejected or
|
||||
failed create persists nothing.
|
||||
"""
|
||||
name = _validate_text(title, "title", required=True)
|
||||
assert name is not None
|
||||
if not isinstance(rule, ScheduleRule):
|
||||
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
|
||||
assignee_ids = _validate_assignee_ids(assignee_child_ids)
|
||||
window_specs = _normalize_windows(windows)
|
||||
description_value = _validate_text(description, "description")
|
||||
icon_value = _validate_text(icon, "icon")
|
||||
|
||||
storage = schedule_rule_to_storage(rule)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
snapshot = await dao.create_with_rule_and_windows(
|
||||
name,
|
||||
storage,
|
||||
_now_stamp(),
|
||||
assignee_ids,
|
||||
window_specs,
|
||||
description=description_value,
|
||||
icon=icon_value,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's assignee check names only the child; re-raise so the
|
||||
# public contract names the field the caller actually passed.
|
||||
message = str(error)
|
||||
if message.startswith("child "):
|
||||
raise ValueError(
|
||||
f"assignee_child_ids: {message}"
|
||||
) from error
|
||||
raise
|
||||
decoded_rule = schedule_rule_from_storage(storage)
|
||||
return CreatedQuestDefinition(
|
||||
definition=snapshot.definition,
|
||||
rule=decoded_rule,
|
||||
assignees=snapshot.assignees,
|
||||
windows=snapshot.windows,
|
||||
)
|
||||
|
||||
|
||||
async def edit_quest_definition(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
*,
|
||||
title: str | object = _UNSET,
|
||||
description: str | None | object = _UNSET,
|
||||
icon: str | None | object = _UNSET,
|
||||
rule: ScheduleRule | object = _UNSET,
|
||||
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Edit a quest definition's metadata, rule and windows atomically.
|
||||
|
||||
Arguments default to the sentinel ``_UNSET`` meaning "leave this
|
||||
field alone". A provided ``title`` is validated like create's
|
||||
(required and trimmed); ``description`` and ``icon`` are optional
|
||||
and may be cleared by passing ``None`` explicitly. A provided
|
||||
``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
|
||||
and REPLACES the definition's schedule rule IN PLACE — the existing
|
||||
``schedule_rules`` row is updated, never orphaned or duplicated. A
|
||||
provided ``windows`` list REPLACES the whole window set (each entry
|
||||
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
|
||||
time), matching create's non-empty contract. Assignment is NOT
|
||||
settable here. Raises ValueError when the definition does not
|
||||
exist or any argument is rejected.
|
||||
|
||||
Returns the updated definition together with its decoded rule,
|
||||
assignees and windows — all read back inside the DAO's transaction,
|
||||
so the returned bundle is a consistent edit snapshot. Edits change
|
||||
future instances only; no ``quest_instances`` or
|
||||
``completion_events`` row is touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date the
|
||||
regeneration's no-past guard and horizon are anchored to (threaded
|
||||
into :func:`~.materialize.regenerate_for_definition`); it is a plain
|
||||
``datetime.date`` with no HA import here — the Feature 09 service
|
||||
supplies the hass-derived value. When omitted, regeneration falls
|
||||
back to the host clock, same as before. ``horizon_days`` sizes the
|
||||
regeneration's re-materialization window the same way (threaded into
|
||||
:func:`~.materialize.regenerate_for_definition`); it defaults to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS` when omitted.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
|
||||
title_value: str | None | object = _UNSET
|
||||
if title is not _UNSET:
|
||||
name = _validate_text(title, "title", required=True)
|
||||
assert name is not None
|
||||
title_value = name
|
||||
|
||||
description_value: str | None | object = _UNSET
|
||||
if description is not _UNSET:
|
||||
description_value = _validate_text(description, "description")
|
||||
|
||||
icon_value: str | None | object = _UNSET
|
||||
if icon is not _UNSET:
|
||||
icon_value = _validate_text(icon, "icon")
|
||||
|
||||
rule_storage: ScheduleRuleStorage | object = _UNSET
|
||||
if rule is not _UNSET:
|
||||
if not isinstance(rule, ScheduleRule):
|
||||
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
|
||||
rule_storage = schedule_rule_to_storage(rule)
|
||||
|
||||
window_specs: list[tuple[str, str | None]] | object = _UNSET
|
||||
if windows is not _UNSET:
|
||||
window_specs = _normalize_windows(windows)
|
||||
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
snapshot = await dao.edit_definition(
|
||||
definition_id,
|
||||
title=title_value,
|
||||
description=description_value,
|
||||
icon=icon_value,
|
||||
rule=rule_storage,
|
||||
windows=window_specs,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's missing-definition error names only the id; re-raise
|
||||
# so the public contract names the field the caller passed (matching
|
||||
# assign/unassign/deactivate).
|
||||
message = str(error)
|
||||
if message.startswith("quest definition "):
|
||||
raise ValueError(f"definition_id: {message}") from error
|
||||
raise
|
||||
|
||||
decoded_rule = schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(snapshot.rule)
|
||||
)
|
||||
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
|
||||
return CreatedQuestDefinition(
|
||||
definition=snapshot.definition,
|
||||
rule=decoded_rule,
|
||||
assignees=snapshot.assignees,
|
||||
windows=snapshot.windows,
|
||||
)
|
||||
|
||||
|
||||
async def assign_child(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> list[ChildRecord]:
|
||||
"""Assign ``child_id`` to the definition (idempotent).
|
||||
|
||||
Validates that both ids are plain ints (bools and floats rejected)
|
||||
and that the child exists and is active — the DAO enforces the
|
||||
active/exists check inside its transaction, and also confirms the
|
||||
definition exists. Assigning an already-assigned child is a no-op
|
||||
(no duplicate row). Returns the definition's assignees read back
|
||||
inside the DAO's same transaction, a consistent post-assignment
|
||||
roster (a concurrent assign/unassign cannot race it after the
|
||||
write). Raises ValueError on any rejected argument. Only the
|
||||
``quest_definition_assignees`` link is written; existing instances
|
||||
and completion history are never touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
into the assignment's regeneration (see :func:`edit_quest_definition`).
|
||||
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||
window the same way.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
_validate_child_id(child_id)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
assignees = await dao.add_assignee(definition_id, child_id)
|
||||
except ValueError as error:
|
||||
# The DAO names only the child/definition; re-raise so the
|
||||
# public contract names the field the caller actually passed.
|
||||
message = str(error)
|
||||
if message.startswith("child "):
|
||||
raise ValueError(f"child_id: {message}") from error
|
||||
if message.startswith("quest definition "):
|
||||
raise ValueError(f"definition_id: {message}") from error
|
||||
raise
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return assignees
|
||||
|
||||
|
||||
async def unassign_child(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> bool:
|
||||
"""Unassign ``child_id`` from the definition; True when removed.
|
||||
|
||||
Validates both ids are plain ints (bools and floats rejected) and
|
||||
that the definition exists, then removes the (definition, child)
|
||||
link. Returns True when a row was removed and False when the child
|
||||
was not currently assigned. Raises ValueError on a rejected
|
||||
argument. Only the ``quest_definition_assignees`` link is written;
|
||||
existing instances and completion history are never touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
into the unassignment's regeneration (see :func:`edit_quest_definition`).
|
||||
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||
window the same way.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
_validate_child_id(child_id)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
definition = await dao.get(definition_id)
|
||||
if definition is None:
|
||||
raise ValueError(
|
||||
f"definition_id: quest definition {definition_id} does not exist"
|
||||
)
|
||||
removed = await dao.remove_assignee(definition_id, child_id)
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return removed
|
||||
|
||||
|
||||
async def set_quest_definition_active(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
is_active: bool,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Deactivate or reactivate a definition; returns the updated view.
|
||||
|
||||
Flipping the flag is the only removal path (Feature 06 guardrail):
|
||||
a deactivated definition stops future instance generation but its
|
||||
row, schedule rule, assignees and windows all survive, and existing
|
||||
instances plus completion history are never touched. Reactivation
|
||||
re-enables future generation; generating or removing instances is
|
||||
Feature 07 and does not happen here.
|
||||
|
||||
``definition_id`` must be a plain int (bools and floats rejected,
|
||||
since SQLite would bind ``True`` onto definition 1) and
|
||||
``is_active`` must be a real bool — the DAO's ``int()`` would
|
||||
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||
state the caller never asked for. Raises ValueError naming the
|
||||
offending field when the id is malformed, ``is_active`` is not a
|
||||
bool, or the definition does not exist.
|
||||
|
||||
Atomicity: the existence check, the UPDATE and the readback of the
|
||||
definition, its rule, assignees and windows all run inside the
|
||||
connection-scoped lock, so the returned bundle is a consistent
|
||||
snapshot that a concurrent mutation cannot race.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
into the activation change's regeneration (see
|
||||
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
|
||||
regeneration's re-materialization window the same way.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
if not isinstance(is_active, bool):
|
||||
raise ValueError(f"is_active must be a real bool, got {is_active!r}")
|
||||
async with _connection_lock(database):
|
||||
dao = QuestDefinitionsDao(database)
|
||||
if await dao.get(definition_id) is None:
|
||||
raise ValueError(
|
||||
f"definition_id: quest definition {definition_id} "
|
||||
"does not exist"
|
||||
)
|
||||
await dao.set_active(definition_id, is_active)
|
||||
updated = await dao.get(definition_id)
|
||||
assert updated is not None
|
||||
rule_record = await ScheduleRulesDao(database).get(
|
||||
updated.schedule_rule_id
|
||||
)
|
||||
assert rule_record is not None
|
||||
assignees = await dao.list_assignees(definition_id)
|
||||
windows = await dao.list_windows(definition_id)
|
||||
rule = schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(rule_record)
|
||||
)
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return CreatedQuestDefinition(
|
||||
definition=updated,
|
||||
rule=rule,
|
||||
assignees=assignees,
|
||||
windows=windows,
|
||||
)
|
||||
|
||||
|
||||
def _bundle_snapshot(
|
||||
snapshot: QuestDefinitionSnapshot,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Decode a DAO snapshot into the layer's rich view.
|
||||
|
||||
The rule record carried by the snapshot is the one the definition
|
||||
referenced at read time, so decoding it here (rather than re-reading
|
||||
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
|
||||
coherence the DAO's locked snapshot guarantees: the rule, assignees
|
||||
and windows all come from one atomic read, never three separate ones.
|
||||
"""
|
||||
rule = schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(snapshot.rule)
|
||||
)
|
||||
return CreatedQuestDefinition(
|
||||
definition=snapshot.definition,
|
||||
rule=rule,
|
||||
assignees=snapshot.assignees,
|
||||
windows=snapshot.windows,
|
||||
)
|
||||
|
||||
|
||||
async def list_active_definitions(
|
||||
database: NestQuestDatabase,
|
||||
) -> list[CreatedQuestDefinition]:
|
||||
"""Return every ACTIVE definition with its decoded rule and links.
|
||||
|
||||
Ordered by rising definition id, matching the DAO's ``list_active``
|
||||
order, so callers get a stable, repeatable sequence. Each entry is
|
||||
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
|
||||
the layer: the stored row plus its decoded
|
||||
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
|
||||
definitions and their links are read by the DAO inside one locked
|
||||
transaction, so each bundle is a coherent snapshot that a concurrent
|
||||
edit cannot interleave.
|
||||
"""
|
||||
dao = QuestDefinitionsDao(database)
|
||||
snapshots = await dao.list_snapshots_active()
|
||||
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
|
||||
|
||||
async def list_definitions_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
) -> list[CreatedQuestDefinition]:
|
||||
"""Return the definitions assigned to ``child_id`` (rich view).
|
||||
|
||||
``child_id`` must be a plain int (bools and floats rejected, since
|
||||
SQLite would bind ``True`` onto child 1). Results are ordered by
|
||||
rising definition id, matching ``list_active_definitions`` and
|
||||
``list_definitions_firing_on``. Each entry is a
|
||||
:class:`CreatedQuestDefinition` snapshot bundling the definition with
|
||||
its decoded rule, assignees and windows, read atomically by the DAO.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
snapshots = await dao.list_snapshots_by_child(child_id)
|
||||
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
return sorted(bundles, key=lambda bundle: bundle.definition.id)
|
||||
|
||||
|
||||
async def list_definitions_firing_on(
|
||||
database: NestQuestDatabase,
|
||||
date: str,
|
||||
) -> list[CreatedQuestDefinition]:
|
||||
"""Return the ACTIVE definitions whose rule fires on ``date``.
|
||||
|
||||
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
|
||||
value raises ValueError naming the field. Each active definition's
|
||||
stored rule is decoded through the task-1 storage mapping and handed
|
||||
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
|
||||
definitions whose decoded rule fires on the date are returned, in
|
||||
rising definition-id order, each as the same rich
|
||||
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
|
||||
The definitions and their rules are read inside one locked
|
||||
transaction, so a bundle can never filter on one rule while
|
||||
presenting another state's windows.
|
||||
"""
|
||||
_validate_date(date, "date")
|
||||
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
|
||||
dao = QuestDefinitionsDao(database)
|
||||
snapshots = await dao.list_snapshots_active()
|
||||
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
|
||||
from .core.quest_definitions import * # noqa: F401,F403
|
||||
|
||||
@@ -1,621 +1,10 @@
|
||||
"""The ScheduleRule model: rule types, validation, and serialization.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package.
|
||||
|
||||
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
|
||||
frozen dataclass with no Home Assistant and no database imports. The
|
||||
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
|
||||
serializes its dict form into the ``schedule_rules`` table.
|
||||
|
||||
Type model: one enum member per recurrence shape. ``MONTHLY_DAY``
|
||||
("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second
|
||||
Tuesday of every month") are distinct types rather than one monthly
|
||||
type with optional fields, so validation is total: a rule either
|
||||
constructs, or raises :class:`RuleValidationError` naming the offending
|
||||
field. Every type carries ONLY the fields its shape needs; carrying
|
||||
any other shape's field is a construction error.
|
||||
|
||||
Storage mapping (Feature 02, ``schedule_rules`` table): the enum's
|
||||
``storage_value`` maps model types onto the schema's CHECK values —
|
||||
MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are
|
||||
disambiguated by which fields are populated; CUSTOM_DAYS stores as
|
||||
``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the
|
||||
table stores (one element for MONTHLY_WEEKDAY, which names the
|
||||
position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field
|
||||
carried through from_dict/to_dict for losslessness and folded into
|
||||
``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the
|
||||
model-side lossless pair; the DB mapping lives in the DAO layer and is
|
||||
exercised there.
|
||||
|
||||
Validation policy (fail fast at construction — the schema's CHECK
|
||||
constraints are a second line of defense, but invalid rules must never
|
||||
reach storage):
|
||||
|
||||
- ``interval`` >= 1 for every rule.
|
||||
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
|
||||
elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans.
|
||||
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
|
||||
- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is
|
||||
rejected) with a weekday 0..6 for MONTHLY_WEEKDAY.
|
||||
- ``month`` 1..12 is required for YEARLY.
|
||||
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
|
||||
when present, on or after ``start_date``.
|
||||
|
||||
Month-end policy (implemented in :func:`_occurs_monthly_day` and
|
||||
documented for Feature 04): in each interval-eligible month, a
|
||||
MONTHLY_DAY rule for a day that does not exist in that month — the
|
||||
31st in April, the 30th or 29th in February, February 29th in a
|
||||
non-leap year — fires on the LAST day of the month instead of being
|
||||
skipped. A monthly chore still happens in every eligible month,
|
||||
pinned to the month's end when its nominal date is absent; exactly one
|
||||
day per eligible month matches the rule (nominal day when it exists,
|
||||
month end when it does not). The interval anchors on nominal months
|
||||
elapsed from start_date's month, so clamping never shifts the cadence.
|
||||
The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .recurrence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
|
||||
_DATE_FORMAT = "%Y-%m-%d"
|
||||
|
||||
#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order).
|
||||
|
||||
|
||||
class RuleType(enum.Enum):
|
||||
"""The six recurrence shapes the engine supports.
|
||||
|
||||
``storage_value`` is the string written to the ``schedule_rules``
|
||||
table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY
|
||||
and MONTHLY_WEEKDAY are distinct shapes sharing one storage value,
|
||||
disambiguated by which fields are populated.
|
||||
"""
|
||||
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY_DAY = "monthly_day"
|
||||
MONTHLY_WEEKDAY = "monthly_weekday"
|
||||
YEARLY = "yearly"
|
||||
CUSTOM_DAYS = "custom_days"
|
||||
|
||||
@property
|
||||
def storage_value(self) -> str:
|
||||
"""The schema-level rule_type string this model type maps to."""
|
||||
return {
|
||||
RuleType.DAILY: "daily",
|
||||
RuleType.WEEKLY: "weekly",
|
||||
RuleType.MONTHLY_DAY: "monthly",
|
||||
RuleType.MONTHLY_WEEKDAY: "monthly",
|
||||
RuleType.YEARLY: "yearly",
|
||||
RuleType.CUSTOM_DAYS: "custom",
|
||||
}[self]
|
||||
|
||||
|
||||
class RuleValidationError(ValueError):
|
||||
"""Raised when a ScheduleRule field combination is invalid."""
|
||||
|
||||
|
||||
def _validate_date(value: str, field_name: str) -> None:
|
||||
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
except (TypeError, ValueError):
|
||||
raise RuleValidationError(
|
||||
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() != value:
|
||||
raise RuleValidationError(
|
||||
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_weekday_set(weekday_set) -> frozenset[int]:
|
||||
"""Return a normalized frozenset or raise with a clear message.
|
||||
|
||||
Sequence entries are validated BEFORE set() coercion: a list like
|
||||
[0, False] must not collapse the boolean into 0, and unhashable
|
||||
entries must raise RuleValidationError, never a raw TypeError.
|
||||
"""
|
||||
if isinstance(weekday_set, (list, tuple)):
|
||||
for entry in weekday_set:
|
||||
# _is_plain_int: bool and int subclasses (potentially
|
||||
# unhashable) are rejected before set() coercion.
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
weekday_set = set(weekday_set)
|
||||
if not isinstance(weekday_set, (set, frozenset)):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set must be a set of weekday integers, got "
|
||||
f"{type(weekday_set).__name__}"
|
||||
)
|
||||
if not weekday_set:
|
||||
raise RuleValidationError(
|
||||
"weekday_set must not be empty for weekly/custom rules"
|
||||
)
|
||||
for entry in weekday_set:
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
if not 0 <= entry <= 6:
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
|
||||
f"got {entry!r}"
|
||||
)
|
||||
return frozenset(weekday_set)
|
||||
|
||||
|
||||
def _is_plain_int(value) -> bool:
|
||||
"""True only for real ints: bool excluded AND int subclasses too
|
||||
(an unhashable int subclass would break the frozen-hashable model)."""
|
||||
return type(value) is int
|
||||
|
||||
|
||||
def _validate_monthly_weekday(nth_weekday, weekday) -> None:
|
||||
"""Validate the MONTHLY_WEEKDAY position fields."""
|
||||
if nth_weekday is None or not _is_plain_int(nth_weekday):
|
||||
raise RuleValidationError(
|
||||
"nth_weekday must be an integer for monthly_weekday rules, "
|
||||
f"got {nth_weekday!r}"
|
||||
)
|
||||
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
|
||||
raise RuleValidationError(
|
||||
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
|
||||
)
|
||||
if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6:
|
||||
raise RuleValidationError(
|
||||
f"monthly_weekday rules require a weekday 0 (Monday)..6 "
|
||||
f"(Sunday), got {weekday!r}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleRule:
|
||||
"""An immutable, validated recurrence rule (see module docstring).
|
||||
|
||||
Construct with keyword arguments; invalid combinations raise
|
||||
:class:`RuleValidationError` at construction time. Fields map
|
||||
one-to-one onto the ``schedule_rules`` table columns, with
|
||||
``nth_weekday_weekday`` as the model-only weekday companion to
|
||||
``nth_weekday`` (serialized inside ``weekday_set`` at the DAO
|
||||
boundary).
|
||||
"""
|
||||
|
||||
rule_type: RuleType
|
||||
interval: int = 1
|
||||
weekday_set: frozenset[int] | None = None
|
||||
day_of_month: int | None = None
|
||||
nth_weekday: int | None = None
|
||||
nth_weekday_weekday: int | None = None
|
||||
month: int | None = None
|
||||
start_date: str = "1970-01-01"
|
||||
end_date: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Normalize a string rule_type into the enum; anything else
|
||||
# that is not already a RuleType is rejected.
|
||||
if isinstance(self.rule_type, str):
|
||||
# EXACT model names only: no whitespace stripping, no storage
|
||||
# aliases. The DAO maps storage strings to model fields when
|
||||
# reading rows; the model boundary stays unambiguous.
|
||||
candidates = {rt.name.lower(): rt for rt in RuleType}
|
||||
if self.rule_type in candidates:
|
||||
object.__setattr__(
|
||||
self, "rule_type", candidates[self.rule_type]
|
||||
)
|
||||
else:
|
||||
raise RuleValidationError(
|
||||
f"rule_type must be a RuleType or one of "
|
||||
f"{sorted(candidates)}, got {self.rule_type!r}"
|
||||
)
|
||||
if not isinstance(self.rule_type, RuleType):
|
||||
raise RuleValidationError(
|
||||
f"rule_type must be a RuleType or one of its string "
|
||||
f"values, got {self.rule_type!r}"
|
||||
)
|
||||
if not _is_plain_int(self.interval):
|
||||
raise RuleValidationError(
|
||||
f"interval must be an integer >= 1, got {self.interval!r}"
|
||||
)
|
||||
if self.interval < 1:
|
||||
raise RuleValidationError(
|
||||
f"interval must be >= 1, got {self.interval!r}"
|
||||
)
|
||||
|
||||
_validate_date(self.start_date, "start_date")
|
||||
if self.end_date is not None:
|
||||
_validate_date(self.end_date, "end_date")
|
||||
if self.end_date < self.start_date:
|
||||
raise RuleValidationError(
|
||||
f"end_date {self.end_date!r} must be on or after "
|
||||
f"start_date {self.start_date!r}"
|
||||
)
|
||||
|
||||
# Type-exclusive field policy, enforced for every type: build
|
||||
# the dict of present fields, then reject every field the shape
|
||||
# must not carry (weekday_set present is detectable via None vs
|
||||
# set even after normalization below).
|
||||
shape = self.rule_type
|
||||
if shape is RuleType.DAILY:
|
||||
forbidden_fields = (
|
||||
"weekday_set",
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.WEEKLY:
|
||||
forbidden_fields = (
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.CUSTOM_DAYS:
|
||||
forbidden_fields = (
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.MONTHLY_DAY:
|
||||
forbidden_fields = ("weekday_set", "nth_weekday",
|
||||
"nth_weekday_weekday", "month")
|
||||
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||
forbidden_fields = ("weekday_set", "day_of_month", "month")
|
||||
elif shape is RuleType.YEARLY:
|
||||
forbidden_fields = (
|
||||
"weekday_set",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
)
|
||||
present_forbidden = [
|
||||
name
|
||||
for name in forbidden_fields
|
||||
if getattr(self, name) is not None
|
||||
]
|
||||
if present_forbidden:
|
||||
raise RuleValidationError(
|
||||
f"{shape.value} rules must not set "
|
||||
f"{', '.join(sorted(present_forbidden))}"
|
||||
)
|
||||
|
||||
# Required fields per shape.
|
||||
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
|
||||
normalized = _validate_weekday_set(self.weekday_set)
|
||||
object.__setattr__(self, "weekday_set", normalized)
|
||||
elif shape is RuleType.MONTHLY_DAY:
|
||||
if self.day_of_month is None or not _is_plain_int(
|
||||
self.day_of_month
|
||||
):
|
||||
raise RuleValidationError(
|
||||
"monthly_day rules require day_of_month (1..31)"
|
||||
)
|
||||
if not 1 <= self.day_of_month <= 31:
|
||||
raise RuleValidationError(
|
||||
f"day_of_month must be 1..31, got {self.day_of_month!r}"
|
||||
)
|
||||
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||
_validate_monthly_weekday(
|
||||
self.nth_weekday, self.nth_weekday_weekday
|
||||
)
|
||||
elif shape is RuleType.YEARLY:
|
||||
if self.month is None or not _is_plain_int(self.month):
|
||||
raise RuleValidationError(
|
||||
"yearly rules require month (1..12)"
|
||||
)
|
||||
if not 1 <= self.month <= 12:
|
||||
raise RuleValidationError(
|
||||
f"month must be 1..12, got {self.month!r}"
|
||||
)
|
||||
if self.day_of_month is not None and not (
|
||||
_is_plain_int(self.day_of_month)
|
||||
and 1 <= self.day_of_month <= 31
|
||||
):
|
||||
raise RuleValidationError(
|
||||
f"day_of_month must be 1..31 for yearly rules, got "
|
||||
f"{self.day_of_month!r}"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Return the lossless dict form (model-side storage shape).
|
||||
|
||||
``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's
|
||||
weekday travels inside it (appended) plus
|
||||
``nth_weekday_weekday`` so the pair round-trips without
|
||||
ambiguity at the model layer.
|
||||
"""
|
||||
return {
|
||||
"rule_type": self.rule_type.name.lower(), # model name
|
||||
"interval": self.interval,
|
||||
"weekday_set": (
|
||||
sorted(self.weekday_set) if self.weekday_set is not None
|
||||
else None
|
||||
),
|
||||
"day_of_month": self.day_of_month,
|
||||
"nth_weekday": self.nth_weekday,
|
||||
"nth_weekday_weekday": self.nth_weekday_weekday,
|
||||
"month": self.month,
|
||||
"start_date": self.start_date,
|
||||
"end_date": self.end_date,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ScheduleRule":
|
||||
"""Rebuild a rule from :meth:`to_dict` output.
|
||||
|
||||
Raises :class:`RuleValidationError` on unknown keys, missing
|
||||
keys, or invalid combinations — never a raw TypeError.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise RuleValidationError(
|
||||
f"rule data must be a dict, got {type(data).__name__}"
|
||||
)
|
||||
allowed = {
|
||||
"rule_type",
|
||||
"interval",
|
||||
"weekday_set",
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
"start_date",
|
||||
"end_date",
|
||||
}
|
||||
unknown = [key for key in data if key not in allowed]
|
||||
if unknown:
|
||||
raise RuleValidationError(
|
||||
f"unknown rule fields: {sorted(unknown, key=repr)}"
|
||||
)
|
||||
if "rule_type" not in data:
|
||||
raise RuleValidationError("missing required rule field: rule_type")
|
||||
weekday_set = data.get("weekday_set")
|
||||
if isinstance(weekday_set, (list, tuple)):
|
||||
# Validate entries as plain ints BEFORE set() coercion, so a
|
||||
# list like [0, False] cannot collapse False into 0 and an
|
||||
# unhashable/int-subclass entry cannot leak a raw TypeError.
|
||||
for entry in weekday_set:
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
weekday_set = set(weekday_set)
|
||||
return cls(
|
||||
rule_type=data["rule_type"],
|
||||
interval=data.get("interval", 1),
|
||||
weekday_set=weekday_set,
|
||||
day_of_month=data.get("day_of_month"),
|
||||
nth_weekday=data.get("nth_weekday"),
|
||||
nth_weekday_weekday=data.get("nth_weekday_weekday"),
|
||||
month=data.get("month"),
|
||||
start_date=data.get("start_date", "1970-01-01"),
|
||||
end_date=data.get("end_date"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_date(value: str) -> datetime.date:
|
||||
"""Parse a strict ISO date already validated by the model."""
|
||||
return datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
|
||||
|
||||
def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int:
|
||||
"""Day offset from the rule's anchor date to ``target``.
|
||||
|
||||
The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the
|
||||
first day the rule CAN fire. A negative offset means the target
|
||||
precedes the rule's window entirely.
|
||||
"""
|
||||
return (target - _parse_date(rule.start_date)).days
|
||||
|
||||
|
||||
def _within_window(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""True when ``target`` is inside the rule's [start, end] window."""
|
||||
if target < _parse_date(rule.start_date):
|
||||
return False
|
||||
if rule.end_date is not None and target > _parse_date(rule.end_date):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _month_end(year: int, month: int) -> int:
|
||||
"""The last calendar day of the given month (leap-aware)."""
|
||||
if month == 12:
|
||||
return 31
|
||||
return (datetime.date(year, month + 1, 1)
|
||||
- datetime.timedelta(days=1)).day
|
||||
|
||||
|
||||
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""MONTHLY_DAY evaluation with the documented month-end policy.
|
||||
|
||||
Policy: in each interval-eligible month, a rule for a day-of-month
|
||||
that does not exist in that month (the 31st in April, the 30th in
|
||||
February, the 29th of February in a non-leap year) fires on the
|
||||
LAST day of that month instead of being skipped — the chore still
|
||||
happens that month, pinned to the month's end when its nominal date
|
||||
is absent.
|
||||
|
||||
The interval anchors on months ELAPSED from start_date's month: the
|
||||
rule fires when the months between the anchor month and the target's
|
||||
month form a multiple of the interval, keeping "every 2 months on
|
||||
the 15th" aligned to the anchor month even when the firing lands on
|
||||
a clamped month-end day.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
months_elapsed = (
|
||||
(target.year - anchor.year) * 12 + (target.month - anchor.month)
|
||||
)
|
||||
if months_elapsed % rule.interval != 0:
|
||||
return False
|
||||
month_end = _month_end(target.year, target.month)
|
||||
return target.day == min(rule.day_of_month, month_end)
|
||||
|
||||
|
||||
def _nth_weekday_of_month(
|
||||
year: int, month: int, weekday: int, nth: int
|
||||
) -> int | None:
|
||||
"""The day-of-month of the ``nth`` ``weekday``, or None when the
|
||||
month has no such occurrence (``nth`` 5 beyond four occurrences).
|
||||
|
||||
Positive ``nth`` counts forward (1 = first); ``-1`` counts from the
|
||||
end (last occurrence, whatever the total). Pure calendar math.
|
||||
"""
|
||||
if nth == -1:
|
||||
last = datetime.date(year, month, _month_end(year, month))
|
||||
back = (last.weekday() - weekday) % 7
|
||||
return last.day - back
|
||||
first = datetime.date(year, month, 1)
|
||||
first_offset = (weekday - first.weekday()) % 7
|
||||
day = 1 + first_offset + (nth - 1) * 7
|
||||
if day > _month_end(year, month):
|
||||
return None
|
||||
return day
|
||||
|
||||
|
||||
def _occurs_monthly_weekday(
|
||||
rule: ScheduleRule, target: datetime.date
|
||||
) -> bool:
|
||||
"""MONTHLY_WEEKDAY evaluation.
|
||||
|
||||
Fires when the target is the ``nth`` occurrence of
|
||||
``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A
|
||||
month with fewer occurrences than ``nth`` (5 on a four-occurrence
|
||||
month) does not fire — n 5 is accepted by the model but skips such
|
||||
months, matching the done-condition. The interval anchors on
|
||||
nominal months elapsed from start_date's month.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
months_elapsed = (
|
||||
(target.year - anchor.year) * 12 + (target.month - anchor.month)
|
||||
)
|
||||
if months_elapsed % rule.interval != 0:
|
||||
return False
|
||||
if target.weekday() != rule.nth_weekday_weekday:
|
||||
return False
|
||||
nth_day = _nth_weekday_of_month(
|
||||
target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday
|
||||
)
|
||||
return nth_day is not None and target.day == nth_day
|
||||
|
||||
|
||||
def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""YEARLY evaluation with the documented leap-day clamp policy.
|
||||
|
||||
Policy: in each interval-eligible year, the rule fires on
|
||||
``day_of_month`` of ``month`` when that day exists. A YEARLY rule
|
||||
for February 29th fires on FEBRUARY 28TH in a non-leap year (the
|
||||
same clamp-as-month-end policy as MONTHLY_DAY: the annual chore
|
||||
still happens every year, pinned to February's end when its
|
||||
nominal date is absent) rather than silently vanishing. A YEARLY
|
||||
rule with NO day_of_month fires on the first day of ``month``.
|
||||
The interval anchors on years elapsed from start_date's year.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
years_elapsed = target.year - anchor.year
|
||||
if years_elapsed % rule.interval != 0:
|
||||
return False
|
||||
if target.month != rule.month:
|
||||
return False
|
||||
if rule.day_of_month is None:
|
||||
# Day-less yearly rules fire on the first of the month.
|
||||
return target.day == 1
|
||||
month_end = _month_end(target.year, target.month)
|
||||
return target.day == min(rule.day_of_month, month_end)
|
||||
|
||||
|
||||
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""Return True when ``rule`` fires on ``target``.
|
||||
|
||||
Pure function: no I/O, no timezone awareness, no mutation. Dates
|
||||
are plain calendar dates (Feature 04 guardrail); the caller decides
|
||||
the timezone that produced them.
|
||||
"""
|
||||
# Dispatch FIRST: an unimplemented shape must raise loudly even for
|
||||
# dates outside the window, never silently evaluate to False.
|
||||
shape = rule.rule_type
|
||||
if shape not in (
|
||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
|
||||
):
|
||||
raise NotImplementedError(
|
||||
f"occurs_on for {shape} lands with its own engine task "
|
||||
"(Features 04 task sequence)"
|
||||
)
|
||||
if not _within_window(rule, target):
|
||||
return False
|
||||
if shape is RuleType.DAILY:
|
||||
offset = (target - _parse_date(rule.start_date)).days
|
||||
return offset % rule.interval == 0
|
||||
if shape is RuleType.MONTHLY_DAY:
|
||||
return _occurs_monthly_day(rule, target)
|
||||
if shape is RuleType.MONTHLY_WEEKDAY:
|
||||
return _occurs_monthly_weekday(rule, target)
|
||||
if shape is RuleType.YEARLY:
|
||||
return _occurs_yearly(rule, target)
|
||||
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
|
||||
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
|
||||
# parity on January 1st; anchor arithmetic does not).
|
||||
anchor = _parse_date(rule.start_date)
|
||||
if shape is RuleType.CUSTOM_DAYS:
|
||||
interval = 1
|
||||
else:
|
||||
interval = rule.interval
|
||||
week_offset = (target - anchor).days // 7
|
||||
if week_offset % interval != 0:
|
||||
return False
|
||||
return target.weekday() in rule.weekday_set
|
||||
|
||||
|
||||
def occurrences_between(
|
||||
rule: ScheduleRule, start: str, end: str
|
||||
) -> list[str]:
|
||||
"""Return every date string in [start, end] the rule fires on.
|
||||
|
||||
Inclusive on both ends. Raises RuleValidationError on malformed or
|
||||
inverted bounds; a window entirely outside [rule.start_date,
|
||||
rule.end_date] yields an empty list.
|
||||
"""
|
||||
if rule.rule_type not in (
|
||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
|
||||
):
|
||||
raise NotImplementedError(
|
||||
f"occurrences_between for {rule.rule_type} lands with its own "
|
||||
"engine task (Features 04 task sequence)"
|
||||
)
|
||||
_validate_date(start, "start")
|
||||
_validate_date(end, "end")
|
||||
start_date = _parse_date(start)
|
||||
end_date = _parse_date(end)
|
||||
if end_date < start_date:
|
||||
raise RuleValidationError(
|
||||
f"end {end!r} must be on or after start {start!r}"
|
||||
)
|
||||
rule_start = _parse_date(rule.start_date)
|
||||
if end_date < rule_start:
|
||||
return []
|
||||
# Clamp the walk to the rule's own window and the queried range.
|
||||
effective_start = max(start_date, rule_start)
|
||||
effective_end = end_date
|
||||
if rule.end_date is not None:
|
||||
effective_end = min(effective_end, _parse_date(rule.end_date))
|
||||
results: list[str] = []
|
||||
cursor = effective_start
|
||||
while cursor <= effective_end:
|
||||
if occurs_on(rule, cursor):
|
||||
results.append(cursor.isoformat())
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return results
|
||||
from .core.recurrence import * # noqa: F401,F403
|
||||
|
||||
@@ -1,427 +1,11 @@
|
||||
"""Declarative schema DDL for the NestQuest SQLite database.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package.
|
||||
|
||||
Every v1 table is declared here as plain SQL statements so later schema
|
||||
tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without
|
||||
touching this module's tables. All statements use
|
||||
``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned
|
||||
migration runner is a separate later concern.
|
||||
|
||||
Timestamp policy: all timestamps are UTC ISO-8601 strings, set by the
|
||||
caller/DAO rather than a DB default, per the feature guardrails. No
|
||||
``length(created_at) = 25`` CHECK is added: the exact string shape
|
||||
produced by callers is not fixed yet (``datetime.isoformat()`` may
|
||||
include microseconds, changing the length), so enforcing a fixed length
|
||||
here would reject valid values. Only non-null is enforced for now.
|
||||
|
||||
Date policy: ``start_date``/``end_date`` are ISO date strings (YYYY-MM-DD,
|
||||
no time component) compared lexically by the ``end_date >= start_date``
|
||||
CHECK; due_date/due_time string shapes are caller-validated by the rule
|
||||
engine and DAO layers, not by DDL.
|
||||
|
||||
``weekday_set`` format: comma-separated weekday integers (CSV), 0 =
|
||||
Monday .. 6 = Sunday, e.g. ``'0,2,4'`` for Mon/Wed/Fri. Chosen over JSON
|
||||
because it sorts, compares and parses trivially in SQL. The format is
|
||||
enforced at the DB level: a CHECK matches ``weekday_set`` against GLOB
|
||||
patterns for element counts 1..7 of single digits 0-6 (GLOB has no
|
||||
repetition quantifier, so the element count is enumerated; more than 7
|
||||
elements is impossible for a weekday set). ``''``, ``'x'``, ``'7'`` and
|
||||
``'0,x'`` are all rejected; NULL stays allowed for non-weekly rules.
|
||||
This keeps the schema layer storage-only — the DAO layer (tasks
|
||||
8c72300c+) does not need its own validator, it can rely on the CHECK.
|
||||
|
||||
Type affinity: SQLite applies column affinity on insert, but CHECKs
|
||||
still observe text and fractional values ('abc' stays text, '1.5'
|
||||
becomes REAL), so every INTEGER column carrying rule semantics carries
|
||||
a ``typeof(...) = 'integer'`` guard in its CHECK.
|
||||
|
||||
Rule-type coherence: two CHECKs enforce that ``weekly`` carries a
|
||||
non-null ``weekday_set``, ``monthly`` carries a ``day_of_month`` or an
|
||||
``nth_weekday``, ``yearly`` carries a ``month``, and ``daily``/``custom``
|
||||
carry none of the month-specific fields. Relaxed deliberately: the DDL
|
||||
does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks"
|
||||
rule or a monthly rule could later be pinned to a month) nor extra
|
||||
fields on weekly/monthly/yearly rules generally, so the CHECKs only
|
||||
reject what the rule engine definitively cannot express now.
|
||||
|
||||
Presence pattern format: ``presence_schedules.pattern`` encodes the
|
||||
whole N-week repeating cycle in one TEXT column as pipe-separated
|
||||
segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
|
||||
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
|
||||
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
|
||||
week 1. An empty segment means the child is absent every day of that
|
||||
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means
|
||||
absent every day of every week, which is distinct from having no
|
||||
schedule row at all (a child with no schedule is present every day, per
|
||||
the feature guardrails). Chosen over JSON or per-week rows to stay
|
||||
consistent with the CSV ``weekday_set`` convention and fully checkable
|
||||
by plain CHECKs. DB-level validation is pure CHECK, no triggers or
|
||||
custom functions: char-class GLOBs reject every character outside
|
||||
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
|
||||
(double comma, comma next to a pipe, leading/trailing comma) and
|
||||
two-digit runs; a further GLOB rejects contiguous runs of 8+ comma
|
||||
separated digits, which caps a segment at 7 elements — the GLOB cannot
|
||||
match across a ``|``, so cross-segment boundaries stay safe; and the
|
||||
segment count is pinned to ``cycle_length_weeks`` by
|
||||
``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per
|
||||
weekday_set this is not full CSV-set normalisation (duplicates like
|
||||
``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``);
|
||||
the engine treats the segment as a set. A
|
||||
one-row-per-``week_index`` normalization was considered and rejected:
|
||||
the flat single-column encoding keeps the done-condition's table shape
|
||||
and proved fully checkable with plain CHECKs.
|
||||
|
||||
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
|
||||
common case, and a small cap keeps anchor arithmetic and the encoded
|
||||
pattern human-readable. Revisit the cap — and re-run the pattern
|
||||
validation matrix in tests — if a longer cycle is ever needed.
|
||||
``anchor_date`` is an ISO date string pinning the cycle: the week index
|
||||
of a date is (days since anchor) // 7 mod ``cycle_length_weeks``,
|
||||
computed by the presence engine (Feature 05) from anchor-date
|
||||
arithmetic, never ISO week parity. Like other date columns it carries
|
||||
no shape CHECK, matching the established date policy.
|
||||
|
||||
Presence overrides: ``presence_overrides.end_date`` is NOT NULL,
|
||||
unlike ``schedule_rules.end_date`` — an override is always a concrete
|
||||
date range; a single-day override stores the same date in both columns.
|
||||
``is_present`` 0/1 marks the child absent/present for the whole range;
|
||||
``note`` is optional free text.
|
||||
|
||||
Multi-assignee definitions (D-008): ``quest_definitions`` carries no
|
||||
``child_id`` column. Assignment lives in
|
||||
``quest_definition_assignees``, one row per (definition, child) with a
|
||||
composite primary key and foreign keys to the definition and the child,
|
||||
so "brush teeth" is one definition covering three children. The
|
||||
migration runner rebuilds a pre-D-008 table into this shape and copies
|
||||
its single-assignee column across. The composite primary key makes
|
||||
duplicate assignment impossible at the storage layer.
|
||||
|
||||
Quest windows (D-008): ``quest_definition_windows`` declares which day
|
||||
windows a definition spans — one row per (definition, window), the
|
||||
composite primary key making a window idempotent per definition.
|
||||
``window`` is CHECK-constrained to the three spellings ``const`` owns;
|
||||
``due_time`` optionally pins a due time inside the window and is
|
||||
caller-validated (strict HH:MM) by the DAO layer, matching the date
|
||||
policy above. The window clock ranges live in ``const.py``, not here:
|
||||
this layer stores what the caller declares and never interprets clocks.
|
||||
|
||||
Quest instances: ``quest_instances`` deliberately carries NO completion
|
||||
status column. An instance's current state derives from the latest row
|
||||
in ``completion_events`` (Feature 08), so materialization (Feature 07)
|
||||
can insert instances without knowing anything about completion and the
|
||||
append-only log stays the single source of truth. ``definition_id``,
|
||||
``child_id``, ``due_date`` and ``window`` snapshot the
|
||||
materialization-time facts ("why this instance exists"): assignment
|
||||
edits and rule edits change future instances only and never rewrite
|
||||
these rows. ``window`` records which declared day window produced the
|
||||
instance (D-008), CHECK-constrained to the same three spellings
|
||||
``const`` owns. ``due_time`` snapshots the window's optional due time
|
||||
at generation for the same reason — the instance keeps its
|
||||
generation-time value even if the definition is edited later.
|
||||
``generated_at`` is a caller-set UTC ISO-8601 timestamp per the module
|
||||
timestamp policy (no DB default, so a generation batch stamps one
|
||||
coherent time).
|
||||
|
||||
Uniqueness: the (definition_id, child_id, due_date, window) tuple is
|
||||
declared as a table-level ``UNIQUE`` constraint rather than a separate
|
||||
``CREATE UNIQUE INDEX`` statement. SQLite materializes it as an
|
||||
implicit unique index (``sqlite_autoindex_quest_instances_1``) — the
|
||||
required "unique index prevents duplicate instances" — and the
|
||||
constraint form cannot be silently dropped the way a standalone index
|
||||
can, so the duplicate door stays shut. Idempotent materialization keys
|
||||
on exactly this tuple: the twice-daily case (one definition, two
|
||||
windows, one child, one date) yields two rows and the shared case (one
|
||||
definition, three assignees, one window) yields three. No secondary
|
||||
indexes beyond it are declared yet: history and board queries (Features
|
||||
08/10/13) should justify their indexes as a later migration once their
|
||||
query shapes exist.
|
||||
|
||||
No ``ON DELETE`` clauses are declared on any foreign key in this
|
||||
module: children and definitions are deactivated, never deleted, so a
|
||||
deletion that would orphan instances or events is a bug and must fail
|
||||
loudly rather than cascade.
|
||||
|
||||
Completion events: ``completion_events`` is strictly append-only. That
|
||||
property is enforced at the DAO layer — the completion-events DAO
|
||||
exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT
|
||||
by database triggers, matching the module's no-trigger policy: a RAISE
|
||||
trigger would be the one exception to "pure CHECK, no triggers",
|
||||
complicate the later migration runner, and still prove nothing (any
|
||||
writer can run raw SQL). ``event_type`` is constrained to
|
||||
``completed``/``uncompleted``; a ``missed`` row is intentionally not
|
||||
representable — missed is a derived state announced on the HA event bus
|
||||
(Feature 10), never an appended event.
|
||||
|
||||
``child_id`` is deliberately denormalized (it is also derivable via
|
||||
``quest_instances``): history reporting (Feature 13) filters and rates
|
||||
per child directly against the log, and the log should stay
|
||||
self-describing without joins.
|
||||
|
||||
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
||||
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
||||
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
||||
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
|
||||
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
|
||||
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
|
||||
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
|
||||
Other actor sources are rejected; if an unattended system actor is
|
||||
ever needed, that is a schema migration, not a silent widening.
|
||||
|
||||
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
||||
``completed`` events — at completion time the due date is known and the
|
||||
flag is decidable. For ``uncompleted`` events the flag is optional:
|
||||
Feature 08 decides whether a reversal carries the reversed completion's
|
||||
on-time flag or NULL, and the DDL deliberately accepts both rather than
|
||||
guessing.
|
||||
The declarative schema DDL lives in
|
||||
:mod:`custom_components.nestquest.core.schema` (extracted in
|
||||
task d0b691d8); this module re-exports it so existing
|
||||
``from .schema import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: GLOB patterns matching a valid ``weekday_set`` CSV: one GLOB per
|
||||
#: possible element count 1..7 (a weekday set cannot exceed 7 elements).
|
||||
#: Each element is a single digit 0-6, separated by a literal comma.
|
||||
#: GLOB has no repetition quantifier, so the element count is enumerated.
|
||||
#: ``''`` and NULL are deliberately NOT matched: NULL is allowed for
|
||||
#: non-weekly rules via a separate ``IS NULL`` arm, and '' is invalid
|
||||
#: because weekly rules need at least one weekday.
|
||||
WEEKDAY_SET_GLOB_PATTERNS: list[str] = [
|
||||
"[0-6]",
|
||||
"[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
]
|
||||
|
||||
SCHEMA_V1_CHILDREN_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS children (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
display_name TEXT NOT NULL,
|
||||
colour TEXT,
|
||||
avatar_ref TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (typeof(sort_order) = 'integer'),
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_ADMIN_USERS_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
ha_user_id TEXT PRIMARY KEY NOT NULL,
|
||||
added_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
_WEEKDAY_SET_CHECK = " OR ".join(
|
||||
f"weekday_set GLOB '{pattern}'" for pattern in WEEKDAY_SET_GLOB_PATTERNS
|
||||
)
|
||||
|
||||
SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_type TEXT NOT NULL CHECK (rule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')),
|
||||
interval INTEGER NOT NULL DEFAULT 1 CHECK (typeof(interval) = 'integer' AND interval >= 1),
|
||||
weekday_set TEXT CHECK (weekday_set IS NULL OR ({_WEEKDAY_SET_CHECK})),
|
||||
day_of_month INTEGER CHECK (day_of_month IS NULL OR (typeof(day_of_month) = 'integer' AND day_of_month >= 1 AND day_of_month <= 31)),
|
||||
nth_weekday INTEGER CHECK (nth_weekday IS NULL OR (typeof(nth_weekday) = 'integer' AND nth_weekday IN (-1, 1, 2, 3, 4, 5))),
|
||||
month INTEGER CHECK (month IS NULL OR (typeof(month) = 'integer' AND month >= 1 AND month <= 12)),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT,
|
||||
CHECK (end_date IS NULL OR end_date >= start_date),
|
||||
CHECK (
|
||||
(rule_type = 'weekly' AND weekday_set IS NOT NULL)
|
||||
OR (rule_type = 'monthly' AND (day_of_month IS NOT NULL OR nth_weekday IS NOT NULL))
|
||||
OR (rule_type = 'yearly' AND month IS NOT NULL)
|
||||
OR (rule_type IN ('daily', 'custom') AND day_of_month IS NULL AND nth_weekday IS NULL AND month IS NULL)
|
||||
)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
#: Column body of ``quest_definitions``, shared between the v1 DDL
|
||||
#: below and the multi-assignee rebuild migration, which must recreate
|
||||
#: the table in exactly this shape (D-008: one definition, many
|
||||
#: assignees via ``quest_definition_assignees`` — no ``child_id``
|
||||
#: column on the definition itself).
|
||||
QUEST_DEFINITIONS_TABLE_SQL = """(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
|
||||
due_time TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definitions {QUEST_DEFINITIONS_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definition_assignees (
|
||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
PRIMARY KEY (definition_id, child_id)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definition_windows (
|
||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
|
||||
due_time TEXT,
|
||||
PRIMARY KEY (definition_id, window)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS presence_schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
|
||||
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
|
||||
anchor_date TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL CHECK (
|
||||
NOT pattern GLOB '*[^0-6,|]*'
|
||||
AND NOT pattern GLOB '*[0-6][0-6]*'
|
||||
AND NOT pattern GLOB '*,,*'
|
||||
AND NOT pattern GLOB '*,|*'
|
||||
AND NOT pattern GLOB '*|,*'
|
||||
AND NOT pattern GLOB ',*'
|
||||
AND NOT pattern GLOB '*,'
|
||||
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
|
||||
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
|
||||
)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS presence_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)),
|
||||
note TEXT,
|
||||
CHECK (end_date >= start_date)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
#: Column body of ``quest_instances``, shared between the v1 DDL
|
||||
#: below and the instance-key rebuild migration, which must recreate
|
||||
#: the table in exactly this shape (D-008: the instance key is
|
||||
#: (definition_id, child_id, due_date, window) — one instance per
|
||||
#: assigned child per declared window per firing date).
|
||||
QUEST_INSTANCES_TABLE_SQL = """(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
|
||||
due_date TEXT NOT NULL,
|
||||
due_time TEXT,
|
||||
generated_at TEXT NOT NULL,
|
||||
UNIQUE (definition_id, child_id, due_date, window)
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
#: Column body of ``completion_events``, shared between the v1 DDL
|
||||
#: below and the actor-child rebuild migration, which must recreate the
|
||||
#: table in exactly this shape (D-008: ``actor_child_id`` records which
|
||||
#: panel profile was tapped — set for panel events, NULL for admin
|
||||
#: ones; legacy panel rows backfill to ``child_id``, the child they
|
||||
#: always were).
|
||||
COMPLETION_EVENTS_TABLE_SQL = """(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
||||
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
||||
actor_user_id TEXT,
|
||||
actor_child_id INTEGER REFERENCES children(id),
|
||||
occurred_at TEXT NOT NULL,
|
||||
was_on_time INTEGER CHECK (
|
||||
was_on_time IS NULL
|
||||
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
||||
),
|
||||
CHECK (
|
||||
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
||||
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
||||
),
|
||||
CHECK (
|
||||
(actor_source = 'user' AND actor_child_id IS NULL)
|
||||
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
|
||||
),
|
||||
CHECK (
|
||||
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
||||
OR (event_type = 'uncompleted')
|
||||
)
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
#: Generic integration-owned key/value state (schema version 7,
|
||||
#: Feature 11): persistent bookkeeping that belongs to the integration
|
||||
#: rather than any domain table — currently the missed-sweep
|
||||
#: watermark. ``value`` is a plain string; callers interpret it.
|
||||
META_STATE_TABLE_SQL = """(
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)"""
|
||||
|
||||
SCHEMA_V7_META_STATE_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_STATEMENTS: list[str] = [
|
||||
*SCHEMA_V1_CHILDREN_DDL,
|
||||
*SCHEMA_V1_ADMIN_USERS_DDL,
|
||||
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
*SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
||||
*SCHEMA_V7_META_STATE_DDL,
|
||||
]
|
||||
|
||||
|
||||
async def async_apply_ddl(
|
||||
database: NestQuestDatabase, statements: list[str]
|
||||
) -> None:
|
||||
"""Apply each DDL statement inside one transaction on ``database``.
|
||||
|
||||
Idempotent: every statement is ``CREATE TABLE IF NOT EXISTS``, so
|
||||
applying the same list twice leaves the schema and its rows intact.
|
||||
"""
|
||||
async with database.transaction():
|
||||
for sql in statements:
|
||||
await database.execute(sql)
|
||||
from .core.schema import * # noqa: F401,F403
|
||||
|
||||
@@ -1,46 +1,11 @@
|
||||
"""Explicit admin-only versus open service registry (Feature 09).
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package.
|
||||
|
||||
One mapping, one file: every registered ``nestquest`` service name is
|
||||
either ``open`` or ``admin_only``. The permission decorator (later
|
||||
Feature 09 tasks) reads this table; it does not take per-handler
|
||||
policy arguments.
|
||||
The service policy registry lives in
|
||||
:mod:`custom_components.nestquest.core.service_policy` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .service_policy import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .const import (
|
||||
DOMAIN_SERVICES,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
|
||||
OPEN = "open"
|
||||
ADMIN_ONLY = "admin_only"
|
||||
|
||||
SERVICE_POLICY: dict[str, str] = {
|
||||
SERVICE_COMPLETE_QUEST: OPEN,
|
||||
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
|
||||
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
|
||||
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
|
||||
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
|
||||
SERVICE_REGENERATE: ADMIN_ONLY,
|
||||
}
|
||||
|
||||
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
|
||||
raise RuntimeError(
|
||||
"SERVICE_POLICY must name exactly the registered nestquest services, "
|
||||
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
|
||||
)
|
||||
from .core.service_policy import * # noqa: F401,F403
|
||||
|
||||
@@ -24,8 +24,6 @@ from . import children as children_layer
|
||||
from . import completion as completion_layer
|
||||
from . import quest_definitions as quest_definitions_layer
|
||||
from .const import (
|
||||
CONF_HORIZON_DAYS,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DOMAIN,
|
||||
DOMAIN_SERVICES,
|
||||
QUEST_WINDOWS,
|
||||
@@ -258,12 +256,7 @@ async def _refresh_entities(runtime: Any) -> None:
|
||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
horizon_days = DEFAULT_HORIZON_DAYS
|
||||
else:
|
||||
horizon_days = value
|
||||
return {"today": today, "horizon_days": horizon_days}
|
||||
return {"today": today, "horizon_days": runtime.settings.horizon_days}
|
||||
|
||||
|
||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
"""SQLite storage path resolution for the NestQuest integration."""
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package.
|
||||
|
||||
The path resolver lives in
|
||||
:mod:`custom_components.nestquest.core.store` (extracted in task
|
||||
d0b691d8) and is Home-Assistant-free:
|
||||
:func:`custom_components.nestquest.core.store.async_get_db_path`
|
||||
takes the full database path. This module re-exports it and provides
|
||||
the original ``async_get_db_path(hass)`` signature so the integration
|
||||
resolves the path from ``hass.config.path`` exactly as before.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import SQLITE_DB_FILENAME
|
||||
from .core.const import SQLITE_DB_FILENAME
|
||||
from .core.store import async_get_db_path as _core_async_get_db_path
|
||||
from .core.store import _ensure_dir
|
||||
|
||||
|
||||
def _ensure_dir(path: Path) -> None:
|
||||
"""Create the given directory and any missing parents."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
__all__ = ["async_get_db_path", "_ensure_dir"]
|
||||
|
||||
|
||||
async def async_get_db_path(hass: HomeAssistant) -> Path:
|
||||
"""Return the absolute path to the NestQuest SQLite database file."""
|
||||
db_path = Path(hass.config.path(SQLITE_DB_FILENAME)).absolute()
|
||||
await hass.async_add_executor_job(_ensure_dir, db_path.parent)
|
||||
return db_path
|
||||
"""Return the absolute path to the NestQuest SQLite database file.
|
||||
|
||||
Resolves the database file from ``hass.config.path`` and delegates to
|
||||
:func:`custom_components.nestquest.core.store.async_get_db_path`,
|
||||
which ensures the parent directory exists off the event loop via
|
||||
:func:`asyncio.to_thread`.
|
||||
"""
|
||||
return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME))
|
||||
|
||||
+56
-1
@@ -22,6 +22,7 @@ pytest -q``); it is never meant to run against a real HA install.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
@@ -829,6 +830,24 @@ def make_hass() -> tuple:
|
||||
return hass, registry
|
||||
|
||||
|
||||
def executor_for(hass):
|
||||
"""Return a dynamic executor callable backed by ``hass.async_add_executor_job``.
|
||||
|
||||
The wrapper resolves ``hass.async_add_executor_job`` on every call, so a
|
||||
test that swaps it after constructing the database (to gate or fail
|
||||
specific jobs) is observed without rebuilding the wrapper — mirroring
|
||||
the pre-extraction db.py, which read the attribute dynamically off
|
||||
``hass``. Use this for cancellation/gating tests that reassign
|
||||
``hass.async_add_executor_job``; plain call sites can pass
|
||||
``hass.async_add_executor_job`` directly.
|
||||
"""
|
||||
|
||||
async def _executor(fn, *args, **kwargs):
|
||||
return await hass.async_add_executor_job(fn, *args, **kwargs)
|
||||
|
||||
return _executor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def hass():
|
||||
"""Provide a standard-shaped hass fixture bound to the running test loop.
|
||||
@@ -898,4 +917,40 @@ def make_config_flow_entry():
|
||||
def _factory(domain: str, entry_id: str = "existing"):
|
||||
return SimpleNamespace(domain=domain, entry_id=entry_id)
|
||||
|
||||
return _factory
|
||||
return _factory
|
||||
|
||||
|
||||
def freeze_nestquest_clock(
|
||||
monkeypatch, instant: datetime.datetime
|
||||
) -> tuple[datetime.datetime, list]:
|
||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||
|
||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched,
|
||||
so the generation path's ``datetime.datetime.now`` reads this instant
|
||||
(localized to whatever time zone it asks for) while every other module
|
||||
and the test harness keep the real clock. Returns ``(frozen, requested)``
|
||||
where ``frozen`` is the instant normalized to UTC and ``requested`` is
|
||||
the list of ``tzinfo`` objects the code under test passed to ``now`` —
|
||||
so a caller can prove the generation path actually requested the
|
||||
configured HA time zone rather than silently reading UTC.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
frozen = instant.astimezone(datetime.timezone.utc)
|
||||
requested: list = []
|
||||
|
||||
class _FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
requested.append(tz)
|
||||
if tz is None:
|
||||
return frozen.replace(tzinfo=None)
|
||||
return frozen.astimezone(tz)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
datetime=_FrozenDatetime,
|
||||
timedelta=datetime.timedelta,
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
@@ -31,7 +31,7 @@ def _make_hass_mock():
|
||||
def _with_db(tmp_path, name):
|
||||
def _run_test(body):
|
||||
async def _main():
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / name)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -33,7 +33,7 @@ def _make_hass_mock():
|
||||
def _with_db(tmp_path, name):
|
||||
def _run_test(body):
|
||||
async def _main():
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / name)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -141,7 +141,7 @@ def test_create_child_duplicate_name_allowed_and_warned(
|
||||
async def _body(database):
|
||||
first = await create_child(database, "Ada")
|
||||
with caplog.at_level(
|
||||
logging.WARNING, logger="custom_components.nestquest.children"
|
||||
logging.WARNING, logger="custom_components.nestquest.core.children"
|
||||
):
|
||||
second = await create_child(database, " ADA ")
|
||||
# Whitespace/case-normalised duplicate: allowed, both rows exist.
|
||||
@@ -360,12 +360,12 @@ def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None:
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "create-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
logger = logging.getLogger(
|
||||
"custom_components.nestquest.children"
|
||||
"custom_components.nestquest.core.children"
|
||||
)
|
||||
seen: list[logging.LogRecord] = []
|
||||
|
||||
@@ -633,7 +633,7 @@ def test_edit_child_concurrent_opposite_edits_return_own_values(
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "edit-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -685,7 +685,7 @@ def test_set_child_active_concurrent_opposite_transitions_return_own(
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "active-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -61,7 +61,7 @@ D1 = _future_day(1)
|
||||
|
||||
|
||||
async def _prepare(path) -> tuple:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
children = ChildrenDao(database)
|
||||
@@ -630,7 +630,7 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "complete-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -1060,7 +1060,7 @@ def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "uncomplete-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -140,7 +140,7 @@ def test_flow_strings_match_translations(
|
||||
def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
|
||||
"""Ensure no module other than const.py hardcodes the domain or db filename."""
|
||||
pkg_dir = Path(config_flow.__file__).parent
|
||||
py_files = [f for f in pkg_dir.glob("*.py") if f.name not in ("const.py",)]
|
||||
py_files = [f for f in pkg_dir.rglob("*.py") if f.name not in ("const.py",)]
|
||||
|
||||
assert len(py_files) > 0, "No python modules found in package to check"
|
||||
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ def test_window_clock_ranges_cover_every_window() -> None:
|
||||
def test_no_hardcoded_domain_or_db_filename_in_modules() -> None:
|
||||
"""Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME."""
|
||||
pkg_dir = Path(const.__file__).parent
|
||||
py_files = [f for f in pkg_dir.glob("*.py") if f.name != "const.py"]
|
||||
py_files = [f for f in pkg_dir.rglob("*.py") if f.name != "const.py"]
|
||||
|
||||
assert len(py_files) > 0, "No python modules found in package to check"
|
||||
|
||||
|
||||
@@ -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:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
_run(database.open(path))
|
||||
_run(apply_migrations(database))
|
||||
return database
|
||||
@@ -319,7 +319,7 @@ def test_reorder_concurrent_calls_serialize(tmp_path) -> None:
|
||||
RuntimeError.
|
||||
"""
|
||||
async def _main() -> None:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / "reorder-concurrent.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -377,7 +377,7 @@ def test_admin_add_concurrent_calls_are_idempotent(tmp_path) -> None:
|
||||
'see no row' and one of them raise IntegrityError.
|
||||
"""
|
||||
async def _main() -> None:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / "admin-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -487,17 +487,17 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
|
||||
scan_roots = [package, repo_root / "tests"]
|
||||
|
||||
allowed = {
|
||||
"custom_components/nestquest/dao_children.py", # the DAO itself
|
||||
"custom_components/nestquest/dao_rules.py", # validates child
|
||||
"custom_components/nestquest/core/dao_children.py", # the DAO itself
|
||||
"custom_components/nestquest/core/dao_rules.py", # validates child
|
||||
# activeness on assignment (FK to children, done-condition
|
||||
# 'validates that the assigned child is active')
|
||||
"custom_components/nestquest/dao_presence.py", # validates the
|
||||
"custom_components/nestquest/core/dao_presence.py", # validates the
|
||||
# schedule/override child exists before writing (same FK shape
|
||||
# as dao_rules: one existence SELECT per write)
|
||||
"custom_components/nestquest/dao_instances.py", # validates the
|
||||
"custom_components/nestquest/core/dao_instances.py", # validates the
|
||||
# instance/event child exists before writing (same FK shape)
|
||||
"custom_components/nestquest/schema.py", # declares the DDL
|
||||
"custom_components/nestquest/migrations.py", # applies the DDL
|
||||
"custom_components/nestquest/core/schema.py", # declares the DDL
|
||||
"custom_components/nestquest/core/migrations.py", # applies the DDL
|
||||
"tests/test_schema.py", # tests the DDL
|
||||
"tests/test_migrations.py", # tests migration application
|
||||
"tests/test_dao_children.py", # this file, scanned separately
|
||||
|
||||
@@ -71,7 +71,7 @@ D20 = _future_day(20)
|
||||
|
||||
|
||||
async def _prepare(path) -> tuple:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
children = ChildrenDao(database)
|
||||
@@ -352,7 +352,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
|
||||
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.nestquest.dao_instances.datetime",
|
||||
"custom_components.nestquest.core.dao_instances.datetime",
|
||||
_ShiftingModule(),
|
||||
)
|
||||
try:
|
||||
@@ -362,7 +362,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
|
||||
# the lock, the IN-LOCK re-check sees tomorrow and must
|
||||
# reject the insert — the stale call-date pre-check alone
|
||||
# would have let it through.
|
||||
import custom_components.nestquest.dao_instances as mod
|
||||
import custom_components.nestquest.core.dao_instances as mod
|
||||
|
||||
async def _lock_holder():
|
||||
# Hold the CONNECTION lock across a transaction until
|
||||
@@ -402,7 +402,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
|
||||
return None
|
||||
|
||||
async def _main():
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / "upsert-rollover.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -1030,8 +1030,8 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None:
|
||||
# INSERT statements are stripped before scanning so only genuine
|
||||
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
|
||||
insert_exempt = {
|
||||
"custom_components/nestquest/dao_instances.py",
|
||||
"custom_components/nestquest/migrations.py", # the rebuild
|
||||
"custom_components/nestquest/core/dao_instances.py",
|
||||
"custom_components/nestquest/core/migrations.py", # the rebuild
|
||||
# migrations re-create the table via INSERT..SELECT (append-
|
||||
# order preserved); UPDATE/DELETE stays forbidden everywhere
|
||||
"tests/test_schema.py",
|
||||
@@ -1141,7 +1141,7 @@ def test_completion_events_dao_instance_has_no_mutation_capability(
|
||||
def test_append_concurrent_events_serialize(tmp_path) -> None:
|
||||
"""Concurrent appends for the same instance all land, in order."""
|
||||
async def _main():
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / "append-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -118,7 +118,7 @@ class World:
|
||||
|
||||
|
||||
async def _world(path) -> World:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
world = World(database)
|
||||
@@ -441,7 +441,7 @@ def test_completion_events_absolutely_no_mutation_path() -> None:
|
||||
)
|
||||
offenders = [
|
||||
py.name
|
||||
for py in sorted(package.glob("*.py"))
|
||||
for py in sorted(package.rglob("*.py"))
|
||||
if py.name != "__pycache__" and mutation.search(py.read_text())
|
||||
]
|
||||
assert offenders == []
|
||||
@@ -33,7 +33,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
||||
|
||||
|
||||
async def _prepare(path) -> tuple:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
schedules = PresenceSchedulesDao(database)
|
||||
@@ -196,7 +196,7 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
|
||||
return fn(*args)
|
||||
|
||||
hass.async_add_executor_job = _gated_executor
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "upsert-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -553,9 +553,9 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
|
||||
scan_roots = [package, repo_root / "tests"]
|
||||
|
||||
allowed = {
|
||||
"custom_components/nestquest/dao_presence.py", # this DAO
|
||||
"custom_components/nestquest/schema.py", # declares the DDL
|
||||
"custom_components/nestquest/migrations.py", # applies the DDL
|
||||
"custom_components/nestquest/core/dao_presence.py", # this DAO
|
||||
"custom_components/nestquest/core/schema.py", # declares the DDL
|
||||
"custom_components/nestquest/core/migrations.py", # applies the DDL
|
||||
"tests/test_schema.py", # tests the DDL
|
||||
"tests/test_migrations.py", # tests migration application
|
||||
"tests/test_dao_presence.py", # this file, scanned separately
|
||||
@@ -766,7 +766,7 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None:
|
||||
return fn(*args)
|
||||
|
||||
hass.async_add_executor_job = _gated_executor
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "override-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -42,7 +42,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
||||
|
||||
|
||||
async def _prepare(path) -> tuple:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
rules = ScheduleRulesDao(database)
|
||||
@@ -325,7 +325,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
||||
create's validation pauses mid-flight before the delete starts.
|
||||
"""
|
||||
async def _main(order: str):
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / f"rule-delete-race-{order}.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -1218,12 +1218,12 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
|
||||
scan_roots = [package, repo_root / "tests"]
|
||||
|
||||
allowed = {
|
||||
"custom_components/nestquest/dao_rules.py", # this DAO
|
||||
"custom_components/nestquest/dao_instances.py", # validates the
|
||||
"custom_components/nestquest/core/dao_rules.py", # this DAO
|
||||
"custom_components/nestquest/core/dao_instances.py", # validates the
|
||||
# definition exists + fetches its assignee before generating an
|
||||
# instance (the instance must snapshot the definition's child)
|
||||
"custom_components/nestquest/schema.py", # declares the DDL
|
||||
"custom_components/nestquest/migrations.py", # applies the DDL
|
||||
"custom_components/nestquest/core/schema.py", # declares the DDL
|
||||
"custom_components/nestquest/core/migrations.py", # applies the DDL
|
||||
"tests/test_schema.py", # tests the DDL
|
||||
"tests/test_migrations.py", # tests migration application
|
||||
"tests/test_dao_rules.py", # this file, scanned separately
|
||||
|
||||
+28
-26
@@ -11,9 +11,9 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import make_hass
|
||||
from conftest import executor_for, make_hass
|
||||
|
||||
from custom_components.nestquest import db as db_module
|
||||
import custom_components.nestquest.core.db as db_module
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ def _make_hass_mock():
|
||||
|
||||
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
|
||||
hass = _make_hass_mock()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
_run(database.open(path))
|
||||
return database, hass
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_open_enables_foreign_key_enforcement(tmp_path) -> None:
|
||||
|
||||
def test_open_delegates_connection_to_executor(tmp_path) -> None:
|
||||
hass = _make_hass_mock()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
_run(database.open(tmp_path / "exec.db"))
|
||||
first_call = hass.async_add_executor_job.call_args_list[0]
|
||||
assert callable(first_call.args[0])
|
||||
@@ -65,7 +65,7 @@ def test_open_delegates_connection_to_executor(tmp_path) -> None:
|
||||
def test_open_twice_returns_same_wrapper_without_second_connect(tmp_path) -> None:
|
||||
path = tmp_path / "once.db"
|
||||
hass = _make_hass_mock()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
first = _run(database.open(path))
|
||||
second = _run(database.open(path))
|
||||
assert first is database
|
||||
@@ -111,7 +111,7 @@ def test_every_method_routes_sqlite3_through_executor(tmp_path) -> None:
|
||||
async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
|
||||
"""The executor surface is awaited per wrapper call on a live loop."""
|
||||
hass = _make_hass_mock()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "loop.db")
|
||||
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
@@ -129,7 +129,7 @@ async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
|
||||
async def test_public_methods_return_only_plain_data(tmp_path) -> None:
|
||||
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
|
||||
hass = _make_hass_mock()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "plain.db")
|
||||
result = await database.execute(
|
||||
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
|
||||
@@ -156,7 +156,7 @@ async def test_public_methods_return_only_plain_data(tmp_path) -> None:
|
||||
async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> None:
|
||||
"""With the conftest hass (real run_in_executor), queries still succeed."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "real.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
assert await database.fetch_one("SELECT 1") == (1,)
|
||||
@@ -210,7 +210,7 @@ async def test_real_executor_never_touches_loop_thread(tmp_path, monkeypatch) ->
|
||||
monkeypatch.setattr(db_module.sqlite3, "connect", _traced_connect)
|
||||
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "threads.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
|
||||
@@ -250,7 +250,9 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
|
||||
"""Every sqlite3.* call in db.py sits inside an executor-delegating function.
|
||||
|
||||
A function is executor-delegating when it (or, for nested closures, any of
|
||||
its enclosing functions) passes work to ``hass.async_add_executor_job``.
|
||||
its enclosing functions) passes work to the wrapper's ``self._executor``
|
||||
callable (the Home-Assistant-free contract that mirrors
|
||||
``hass.async_add_executor_job``).
|
||||
"""
|
||||
source = Path(db_module.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(db_module.__file__))
|
||||
@@ -262,7 +264,7 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
|
||||
and any(
|
||||
isinstance(sub, ast.Call)
|
||||
and isinstance(sub.func, ast.Attribute)
|
||||
and sub.func.attr == "async_add_executor_job"
|
||||
and sub.func.attr == "_executor"
|
||||
for sub in ast.walk(node)
|
||||
)
|
||||
}
|
||||
@@ -375,7 +377,7 @@ async def test_commit_failure_rolls_back_and_next_transaction_works(
|
||||
must run cleanly.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "deferred.db")
|
||||
await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
|
||||
await database.execute(
|
||||
@@ -416,7 +418,7 @@ async def test_cancel_during_open_closes_abandoned_connection(tmp_path) -> None:
|
||||
propagates, and a follow-up open() must succeed cleanly.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(executor_for(hass))
|
||||
|
||||
open_started = asyncio.Event()
|
||||
release_open = asyncio.Event()
|
||||
@@ -481,7 +483,7 @@ async def test_cancel_during_begin_settles_then_rolls_back(tmp_path) -> None:
|
||||
clear its state, and stay usable for the next transaction.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(executor_for(hass))
|
||||
await database.open(tmp_path / "cancel-begin.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
|
||||
@@ -543,7 +545,7 @@ async def test_cancel_during_commit_settles_then_propagates_cancellation(
|
||||
transaction, and the wrapper must stay usable.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(executor_for(hass))
|
||||
await database.open(tmp_path / "cancel-commit.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
|
||||
@@ -603,7 +605,7 @@ async def test_cancel_during_in_transaction_execute_settles_then_rolls_back(
|
||||
not persist.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(executor_for(hass))
|
||||
await database.open(tmp_path / "cancel-execute.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
|
||||
@@ -666,7 +668,7 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None
|
||||
commit failure behind the rollback failure.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(executor_for(hass))
|
||||
await database.open(tmp_path / "both-fail.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
|
||||
@@ -698,14 +700,14 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None
|
||||
async def test_transaction_persists_across_connections(tmp_path) -> None:
|
||||
"""A committed transaction is visible to a brand-new connection."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "persist.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
async with database.transaction():
|
||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
|
||||
await database.close()
|
||||
|
||||
other = NestQuestDatabase(hass)
|
||||
other = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await other.open(tmp_path / "persist.db")
|
||||
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
|
||||
await other.close()
|
||||
@@ -726,7 +728,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
|
||||
while a transaction is open queue behind it on the wrapper lock.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "serial.db")
|
||||
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
|
||||
tx_rounds, writers, writer_rounds = 6, 5, 8
|
||||
@@ -766,7 +768,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
|
||||
async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> None:
|
||||
"""A second transaction caller is rejected with a clear error."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "reject.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
||||
|
||||
@@ -791,7 +793,7 @@ async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> Non
|
||||
async def test_transaction_blocks_other_statements_until_commit(tmp_path) -> None:
|
||||
"""A statement issued during an open transaction queues behind it."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "block.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
|
||||
@@ -830,7 +832,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error(
|
||||
) -> None:
|
||||
"""A second concurrent transaction() gets a clear RuntimeError."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "concurrent-tx.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
||||
|
||||
@@ -853,7 +855,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error(
|
||||
async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
|
||||
"""close() serializes: an open transaction finishes before shutdown."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "closerace.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
|
||||
@@ -875,7 +877,7 @@ async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
|
||||
await asyncio.gather(txn_task, close_task)
|
||||
assert database.connected is False
|
||||
|
||||
witness = NestQuestDatabase(hass)
|
||||
witness = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await witness.open(tmp_path / "closerace.db")
|
||||
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
|
||||
await witness.close()
|
||||
@@ -886,7 +888,7 @@ async def test_close_during_transaction_from_same_task_is_rejected(
|
||||
) -> None:
|
||||
"""close() inside its own transaction body raises instead of deadlocking."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "selfclose.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
||||
with pytest.raises(RuntimeError, match="transaction"):
|
||||
|
||||
+423
-9
@@ -31,7 +31,19 @@ ADMIN_CTX = {"user_id": "admin-1"}
|
||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
||||
|
||||
|
||||
async def _setup_and_seed(hass, make_entry, *, windows=("morning",)):
|
||||
async def _setup_and_seed(hass, make_entry, *, windows=("morning",), seed_date=None):
|
||||
"""Seed a household and materialize today's instances.
|
||||
|
||||
``seed_date`` pins the date instances are materialized for (and the
|
||||
daily rule's ``start_date``); it defaults to the host's
|
||||
``datetime.date.today()``. Callers that need to test the day-complete
|
||||
rule against a non-UTC HA-local timezone pass the local zone's
|
||||
``today`` here so the seeded instance's ``due_date`` matches it.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
if seed_date is None:
|
||||
seed_date = datetime.date.today()
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
|
||||
)
|
||||
@@ -43,7 +55,6 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",)):
|
||||
{"action": "create", "display_name": "Ada"},
|
||||
context=ADMIN_CTX,
|
||||
)
|
||||
child = None
|
||||
from custom_components.nestquest.children import list_children
|
||||
|
||||
child = (await list_children(database))[0]
|
||||
@@ -51,18 +62,16 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",)):
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": __import__("datetime").date.today().isoformat()}
|
||||
{"rule_type": "daily", "start_date": seed_date.isoformat()}
|
||||
),
|
||||
[child.id],
|
||||
list(windows),
|
||||
)
|
||||
import datetime
|
||||
|
||||
today = datetime.date.today().isoformat()
|
||||
end = (datetime.date.today() + datetime.timedelta(days=3)).isoformat()
|
||||
await materialize(database, today, end, today=datetime.date.today())
|
||||
today_iso = seed_date.isoformat()
|
||||
end_iso = (seed_date + datetime.timedelta(days=3)).isoformat()
|
||||
await materialize(database, today_iso, end_iso, today=seed_date)
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, today, today
|
||||
child.id, today_iso, today_iso
|
||||
)
|
||||
return entry, child, instances
|
||||
|
||||
@@ -377,3 +386,408 @@ async def test_forced_refreshes_serialize_no_stale_publish(
|
||||
assert order == ["start", "end", "start", "end"], (
|
||||
"overlapping refreshes must serialize: " + str(order)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct core-builder unit tests (P2.2a): exact payload key sets, event
|
||||
# order, and was_on_time threading. These target
|
||||
# custom_components.nestquest.core.events directly, bypassing the shim,
|
||||
# so a payload-shape regression is caught even if the shim masks it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMPLETED_KEYS = {
|
||||
"child_id",
|
||||
"child_name",
|
||||
"instance_id",
|
||||
"quest_title",
|
||||
"window",
|
||||
"due_date",
|
||||
"due_time",
|
||||
"occurred_at",
|
||||
"was_on_time",
|
||||
}
|
||||
_UNCOMPLETED_KEYS = {
|
||||
"child_id",
|
||||
"child_name",
|
||||
"instance_id",
|
||||
"quest_title",
|
||||
"window",
|
||||
"due_date",
|
||||
"due_time",
|
||||
"occurred_at",
|
||||
}
|
||||
_DAY_COMPLETE_KEYS = {
|
||||
"child_id",
|
||||
"child_name",
|
||||
"quests_due",
|
||||
"quests_completed",
|
||||
"occurred_at",
|
||||
}
|
||||
|
||||
|
||||
async def test_build_quest_completed_event_payload_key_set(hass, make_entry) -> None:
|
||||
"""The completed-event payload carries exactly the documented keys
|
||||
(no extra fields, no missing field) and threads ``was_on_time``."""
|
||||
from custom_components.nestquest.core.events import build_quest_completed_event
|
||||
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
event_type, payload = await build_quest_completed_event(
|
||||
database, instances[0].id, was_on_time=True
|
||||
)
|
||||
assert event_type == EVENT_QUEST_COMPLETED
|
||||
assert set(payload) == _COMPLETED_KEYS, (
|
||||
f"completed payload keys drift: got {set(payload)} expected {_COMPLETED_KEYS}"
|
||||
)
|
||||
assert payload["was_on_time"] is True
|
||||
assert payload["child_id"] == child.id
|
||||
assert payload["child_name"] == "Ada"
|
||||
assert payload["instance_id"] == instances[0].id
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"]
|
||||
|
||||
|
||||
async def test_build_quest_completed_event_was_on_time_threads_all_shapes(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""``was_on_time`` is carried verbatim — True, False, and None
|
||||
(the unknown-timing shape) all round-trip into the payload."""
|
||||
from custom_components.nestquest.core.events import build_quest_completed_event
|
||||
|
||||
entry, _child, instances = await _setup_and_seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
for wanted in (True, False, None):
|
||||
_etype, payload = await build_quest_completed_event(
|
||||
database, instances[0].id, was_on_time=wanted
|
||||
)
|
||||
assert payload["was_on_time"] is wanted, (
|
||||
f"was_on_time={wanted!r} did not thread through"
|
||||
)
|
||||
|
||||
|
||||
async def test_build_quest_uncompleted_events_payload_key_set(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The uncompleted-event payload carries exactly the documented keys
|
||||
(no ``was_on_time`` — that field is completion-only)."""
|
||||
from custom_components.nestquest.core.events import (
|
||||
build_quest_uncompleted_events,
|
||||
)
|
||||
|
||||
entry, _child, instances = await _setup_and_seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
events = await build_quest_uncompleted_events(database, instances[0].id)
|
||||
assert len(events) == 1
|
||||
event_type, payload = events[0]
|
||||
assert event_type == EVENT_QUEST_UNCOMPLETED
|
||||
assert set(payload) == _UNCOMPLETED_KEYS, (
|
||||
f"uncompleted payload keys drift: got {set(payload)} "
|
||||
f"expected {_UNCOMPLETED_KEYS}"
|
||||
)
|
||||
assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"]
|
||||
|
||||
|
||||
async def test_build_child_day_complete_event_payload_key_set(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The day-complete payload carries exactly the documented keys
|
||||
when the child's whole day clears, and reuses the completed
|
||||
event's ``occurred_at`` stamp (one transition, one timestamp)."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.core.events import (
|
||||
build_child_day_complete_event,
|
||||
build_quest_completed_event,
|
||||
)
|
||||
|
||||
entry, _child, instances = await _setup_and_seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
today = datetime.date.today()
|
||||
completed_type, completed_payload = await build_quest_completed_event(
|
||||
database, instances[0].id, was_on_time=True
|
||||
)
|
||||
# Mark every today-instance done so the day clears.
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
|
||||
for instance in instances:
|
||||
await complete_instance(
|
||||
database,
|
||||
instance.id,
|
||||
actor_source="panel",
|
||||
actor_child_id=_child.id,
|
||||
today=today,
|
||||
)
|
||||
day_event = await build_child_day_complete_event(
|
||||
database, completed_payload, today=today
|
||||
)
|
||||
assert day_event is not None
|
||||
event_type, day_payload = day_event
|
||||
assert event_type == EVENT_CHILD_DAY_COMPLETE
|
||||
assert set(day_payload) == _DAY_COMPLETE_KEYS, (
|
||||
f"day-complete payload keys drift: got {set(day_payload)} "
|
||||
f"expected {_DAY_COMPLETE_KEYS}"
|
||||
)
|
||||
assert day_payload["occurred_at"] == completed_payload["occurred_at"], (
|
||||
"day-complete must reuse the completed event's occurred_at stamp"
|
||||
)
|
||||
assert day_payload["quests_due"] == len(instances)
|
||||
assert day_payload["quests_completed"] == len(instances)
|
||||
|
||||
|
||||
async def test_shim_fires_completed_before_day_complete(hass, make_entry) -> None:
|
||||
"""The shim fires ``nestquest_quest_completed`` BEFORE
|
||||
``nestquest_child_day_complete`` (the pre-extraction ordering), so
|
||||
a failure in the day-complete DB reads cannot suppress the
|
||||
completed event. Asserted by bus event ORDER, not just counts."""
|
||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||
for instance in instances:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instance.id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": child.id,
|
||||
},
|
||||
)
|
||||
ordered = [
|
||||
etype for etype, _payload in hass.bus.events
|
||||
if etype in (EVENT_QUEST_COMPLETED, EVENT_CHILD_DAY_COMPLETE)
|
||||
]
|
||||
# The completed event must precede the day-complete event.
|
||||
first_completed = ordered.index(EVENT_QUEST_COMPLETED)
|
||||
first_day = ordered.index(EVENT_CHILD_DAY_COMPLETE)
|
||||
assert first_completed < first_day, (
|
||||
f"completed must fire before day-complete; order was {ordered}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Day-complete rule coverage (P2.2b): an explicit ``today`` proves the
|
||||
# four outcomes — zero-quest day, non-today instance, partial day, and
|
||||
# fully-cleared today.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_day_complete_zero_quest_day_yields_no_event(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A zero-quest day never fires day-complete: when the completed
|
||||
payload's ``due_date`` matches ``today`` but the child has NO
|
||||
instances due that date, the builder returns ``None`` (the
|
||||
all-done binary sensor's rule — a day with zero quests owed can
|
||||
never be "cleared")."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.core.events import (
|
||||
build_child_day_complete_event,
|
||||
build_quest_completed_event,
|
||||
)
|
||||
|
||||
entry, _child, instances = await _setup_and_seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
# A date the daily rule does NOT cover (well past the materialized
|
||||
# horizon) — zero instances due this date.
|
||||
zero_quest_day = datetime.date.today() + datetime.timedelta(days=365)
|
||||
_etype, completed_payload = await build_quest_completed_event(
|
||||
database, instances[0].id, was_on_time=True
|
||||
)
|
||||
# Override due_date to the zero-quest day so the builder evaluates
|
||||
# it (otherwise the due_date != today short-circuit fires first).
|
||||
crafted = dict(completed_payload, due_date=zero_quest_day.isoformat())
|
||||
result = await build_child_day_complete_event(
|
||||
database, crafted, today=zero_quest_day
|
||||
)
|
||||
assert result is None, (
|
||||
"a zero-quest day must not yield a day-complete event"
|
||||
)
|
||||
|
||||
|
||||
async def test_day_complete_non_today_instance_yields_no_event(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Completing an instance NOT due today never evaluates the
|
||||
child's day-complete for today: only the completed event fires."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.core.events import (
|
||||
build_child_day_complete_event,
|
||||
build_quest_completed_event,
|
||||
)
|
||||
|
||||
entry, _child, instances = await _setup_and_seed(hass, make_entry)
|
||||
database = entry.runtime_data.database
|
||||
# Complete a FUTURE instance (tomorrow), pass today = today.
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
|
||||
today = datetime.date.today()
|
||||
end = (today + datetime.timedelta(days=3)).isoformat()
|
||||
all_instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
_child.id, today.isoformat(), end
|
||||
)
|
||||
today_ids = {i.id for i in instances}
|
||||
tomorrow_instance = next(i for i in all_instances if i.id not in today_ids)
|
||||
_etype, completed_payload = await build_quest_completed_event(
|
||||
database, tomorrow_instance.id, was_on_time=True
|
||||
)
|
||||
assert completed_payload["due_date"] != today.isoformat()
|
||||
result = await build_child_day_complete_event(
|
||||
database, completed_payload, today=today
|
||||
)
|
||||
assert result is None, (
|
||||
"a non-today instance must not yield a day-complete event for today"
|
||||
)
|
||||
|
||||
|
||||
async def test_day_complete_partial_day_yields_no_event(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A partial day (some quests still open) yields only the
|
||||
completed event — day-complete does not fire until the WHOLE day
|
||||
clears."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.core.events import (
|
||||
build_child_day_complete_event,
|
||||
build_quest_completed_event,
|
||||
)
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
|
||||
entry, child, instances = await _setup_and_seed(
|
||||
hass, make_entry, windows=("morning", "evening")
|
||||
)
|
||||
assert len(instances) == 2
|
||||
database = entry.runtime_data.database
|
||||
today = datetime.date.today()
|
||||
# Complete ONLY the morning instance — the evening one stays open.
|
||||
await complete_instance(
|
||||
database,
|
||||
instances[0].id,
|
||||
actor_source="panel",
|
||||
actor_child_id=child.id,
|
||||
today=today,
|
||||
)
|
||||
_etype, completed_payload = await build_quest_completed_event(
|
||||
database, instances[0].id, was_on_time=True
|
||||
)
|
||||
result = await build_child_day_complete_event(
|
||||
database, completed_payload, today=today
|
||||
)
|
||||
assert result is None, (
|
||||
"a partial day (evening quest still open) must not fire day-complete"
|
||||
)
|
||||
|
||||
|
||||
async def test_day_complete_fully_cleared_today_yields_event(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A fully-cleared today (all owed quests done) yields the
|
||||
completed event AND the child_day_complete event."""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.core.events import (
|
||||
build_child_day_complete_event,
|
||||
build_quest_completed_event,
|
||||
)
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
|
||||
entry, child, instances = await _setup_and_seed(
|
||||
hass, make_entry, windows=("morning", "evening")
|
||||
)
|
||||
assert len(instances) == 2
|
||||
database = entry.runtime_data.database
|
||||
today = datetime.date.today()
|
||||
for instance in instances:
|
||||
await complete_instance(
|
||||
database,
|
||||
instance.id,
|
||||
actor_source="panel",
|
||||
actor_child_id=child.id,
|
||||
today=today,
|
||||
)
|
||||
# The last completion's payload drives the day-complete evaluation.
|
||||
_etype, completed_payload = await build_quest_completed_event(
|
||||
database, instances[-1].id, was_on_time=True
|
||||
)
|
||||
result = await build_child_day_complete_event(
|
||||
database, completed_payload, today=today
|
||||
)
|
||||
assert result is not None, (
|
||||
"a fully-cleared today must yield the day-complete event"
|
||||
)
|
||||
assert result[0] == EVENT_CHILD_DAY_COMPLETE
|
||||
assert result[1]["quests_due"] == len(instances)
|
||||
assert result[1]["quests_completed"] == len(instances)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shim timezone wiring (P2.2c): the conftest fake hass defaults
|
||||
# hass.config.time_zone to UTC, so the day-complete rule's HA-local
|
||||
# ``today`` wiring is otherwise untested. This test sets the zone to
|
||||
# one whose current local date differs from UTC and asserts the
|
||||
# day-complete rule uses THAT local date, not UTC.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_shim_day_complete_uses_ha_local_timezone(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The day-complete rule reads ``hass.config.time_zone`` and
|
||||
derives ``today`` in HA-local time, not UTC.
|
||||
|
||||
Picks a zone whose current local date differs from UTC, seeds an
|
||||
instance for THAT zone's today (not UTC's today), completes it
|
||||
through the service path, and asserts
|
||||
``nestquest_child_day_complete`` fires — proving the local date
|
||||
reached the day-complete rule. If no candidate zone has a
|
||||
different date right now (only possible inside the ~1h window
|
||||
around 12:00 UTC when all zones share one date), the test skips
|
||||
rather than run vacuously.
|
||||
"""
|
||||
import datetime as _dt
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
utc_today = _dt.datetime.now(_dt.timezone.utc).date()
|
||||
far_zone = None
|
||||
far_today = None
|
||||
for tz_name in (
|
||||
"Pacific/Kiritimati", # +14 (UTC+14)
|
||||
"Pacific/Auckland", # +12/+13
|
||||
"Pacific/Chatham", # +12:45/+13:45
|
||||
"Pacific/Honolulu", # -10
|
||||
"America/Los_Angeles", # -8/-7
|
||||
"Etc/GMT+12", # -12
|
||||
"Etc/GMT-13", # +13
|
||||
):
|
||||
local_today = _dt.datetime.now(ZoneInfo(tz_name)).date()
|
||||
if local_today != utc_today:
|
||||
far_zone = tz_name
|
||||
far_today = local_today
|
||||
break
|
||||
if far_zone is None:
|
||||
import pytest
|
||||
|
||||
pytest.skip(
|
||||
"no candidate zone has a date different from UTC right now"
|
||||
)
|
||||
|
||||
hass.config.time_zone = far_zone
|
||||
entry, child, instances = await _setup_and_seed(
|
||||
hass, make_entry, seed_date=far_today
|
||||
)
|
||||
for instance in instances:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": instance.id,
|
||||
"actor": "panel",
|
||||
"actor_child_id": child.id,
|
||||
},
|
||||
)
|
||||
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
|
||||
assert len(day_complete) == 1, (
|
||||
f"day-complete must fire under {far_zone} (local today "
|
||||
f"{far_today.isoformat()}, UTC today {utc_today.isoformat()}); "
|
||||
f"got {len(day_complete)} events"
|
||||
)
|
||||
|
||||
+74
-19
@@ -12,6 +12,7 @@ from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
SERVICE_REGENERATE,
|
||||
DOMAIN as DOMAIN_CONST,
|
||||
@@ -36,12 +37,12 @@ async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
|
||||
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
||||
|
||||
|
||||
async def test_setup_runtime_data_has_entry_id_and_options(hass, make_entry) -> None:
|
||||
async def test_setup_runtime_data_has_entry_id_and_settings(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
runtime = entry.runtime_data
|
||||
assert runtime.entry_id == entry.entry_id
|
||||
assert runtime.options == {"horizon_days": 7}
|
||||
assert runtime.settings.horizon_days == 7
|
||||
assert callable(runtime.remove_update_listener)
|
||||
|
||||
|
||||
@@ -472,7 +473,7 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
|
||||
# Seed the database BEFORE setup: a restart reopens an existing file that
|
||||
# already holds definitions. The startup backfill must pick them up.
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
@@ -499,7 +500,7 @@ async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
@@ -526,24 +527,78 @@ async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None
|
||||
).list_by_date_range(child_id, beyond, beyond_end) == []
|
||||
|
||||
|
||||
def test_configured_horizon_days_validates_and_falls_back() -> None:
|
||||
"""The configured horizon is validated; a malformed value uses the default."""
|
||||
import custom_components.nestquest as nestquest
|
||||
async def test_setup_succeeds_with_fully_malformed_options(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A fully malformed options dict falls back per field and setup succeeds.
|
||||
|
||||
assert nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 5}) == 5
|
||||
assert nestquest._configured_horizon_days({}) == DEFAULT_HORIZON_DAYS
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: True})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
``horizon_days=True``, ``day_rollover_time='99:99'`` and
|
||||
``morning_summary_time='25:00'`` are all invalid; the integration's
|
||||
``from_options_resilient`` falls each back to its default, so setup
|
||||
SUCCEEDS, the day-rollover listener registers at the default hour 0
|
||||
minute 0, and the startup backfill materializes the default horizon
|
||||
window.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(
|
||||
make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: True,
|
||||
CONF_DAY_ROLLOVER_TIME: "99:99",
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: "5"})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 0})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
# The day-rollover listener fell back to the default 00:00.
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert registration["hour"] == 0
|
||||
assert registration["minute"] == 0
|
||||
|
||||
# The startup backfill used the default horizon window.
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child_id, today.isoformat(), end)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_setup_keeps_horizon_when_sibling_option_is_bad(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A malformed sibling option does not reset the configured horizon.
|
||||
|
||||
``horizon_days=5`` is valid while ``morning_summary_time='25:00'`` is
|
||||
invalid; ``from_options_resilient`` falls back ONLY the bad sibling,
|
||||
so the entry's resolved settings keep ``horizon_days == 5``.
|
||||
"""
|
||||
entry = _wire(
|
||||
make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 5,
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert entry.runtime_data.settings.horizon_days == 5
|
||||
|
||||
|
||||
async def test_regenerate_service_registered_and_materializes(
|
||||
|
||||
@@ -51,7 +51,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
||||
|
||||
|
||||
async def _prepare(path):
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
return database
|
||||
@@ -1039,7 +1039,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight(
|
||||
clock["reads"] += 1
|
||||
return day0 if clock["reads"] == 1 else day1
|
||||
|
||||
import custom_components.nestquest.dao_instances as dao_instances_module
|
||||
import custom_components.nestquest.core.dao_instances as dao_instances_module
|
||||
|
||||
monkeypatch.setattr(dao_instances_module, "_today", _fake_today)
|
||||
|
||||
@@ -1475,7 +1475,7 @@ async def _collect_records(database, child_ids, start, end) -> set:
|
||||
|
||||
|
||||
def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None:
|
||||
import custom_components.nestquest.materialize as materialize_module
|
||||
import custom_components.nestquest.core.materialize as materialize_module
|
||||
|
||||
# Give each materialize run a DISTINCT batch stamp: production upserts
|
||||
# rewrite generated_at on every re-run, so a shared pinned stamp would
|
||||
@@ -1487,6 +1487,9 @@ def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None:
|
||||
stamps["n"] += 1
|
||||
return f"2026-06-01T00:00:{stamps['n']:02d}+00:00"
|
||||
|
||||
# The materialize() walk reads ``_now_stamp`` from its OWN module
|
||||
# globals (now the HA-free core package), so the patch must target
|
||||
# the core module, not the re-export shim the integration imports.
|
||||
monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp)
|
||||
|
||||
async def _body(database):
|
||||
|
||||
@@ -37,7 +37,7 @@ def _make_hass_mock():
|
||||
|
||||
|
||||
def _open_db(path) -> NestQuestDatabase:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
_run(database.open(path))
|
||||
return database
|
||||
|
||||
@@ -707,7 +707,7 @@ def test_noop_run_logs_no_migration(caplog, tmp_path) -> None:
|
||||
try:
|
||||
_migrate(database)
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.INFO, logger="custom_components.nestquest.migrations"):
|
||||
with caplog.at_level(logging.INFO, logger="custom_components.nestquest.core.migrations"):
|
||||
_migrate(database)
|
||||
migrated = [
|
||||
r for r in caplog.records if "migrated" in r.getMessage().lower()
|
||||
@@ -722,7 +722,7 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None:
|
||||
try:
|
||||
_migrate(database)
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.migrations"):
|
||||
with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.core.migrations"):
|
||||
_migrate(database)
|
||||
assert any(
|
||||
f"already at version {len(MIGRATIONS)}" in r.getMessage()
|
||||
@@ -983,7 +983,7 @@ def test_concurrent_runners_serialize_and_apply_once(tmp_path) -> None:
|
||||
results: dict[str, int] = {}
|
||||
|
||||
async def _main(tmp_name: str) -> None:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_name)
|
||||
try:
|
||||
# Start A, yield so it acquires the lock and enters its
|
||||
|
||||
@@ -113,7 +113,7 @@ def _open_allowlist_db(tmp_path, name):
|
||||
|
||||
hass = MagicMock()
|
||||
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
_run(database.open(tmp_path / name))
|
||||
_run(apply_migrations(database))
|
||||
return database
|
||||
|
||||
+10
-2
@@ -364,11 +364,19 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None:
|
||||
|
||||
|
||||
def test_module_uses_no_iso_week_functions() -> None:
|
||||
"""D-004 guardrail: cycle math never reads ISO week numbers."""
|
||||
"""D-004 guardrail: cycle math never reads ISO week numbers.
|
||||
|
||||
Scanned against ``custom_components.nestquest.core.presence`` (the
|
||||
real presence model since task 510c1f78); the integration's
|
||||
``presence.py`` is now a re-export shim, so scanning the shim is
|
||||
vacuous. Mirrors ``test_recurrence_model.py``'s scan of
|
||||
``core.recurrence``.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
source = Path(
|
||||
__import__("custom_components.nestquest.presence", fromlist=["__file__"]).__file__
|
||||
__import__("custom_components.nestquest.core.presence",
|
||||
fromlist=["__file__"]).__file__
|
||||
).read_text()
|
||||
assert "isocalendar" not in source
|
||||
assert "isoweek" not in source
|
||||
|
||||
@@ -50,7 +50,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
||||
|
||||
|
||||
async def _prepare(path) -> tuple:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
children = ChildrenDao(database)
|
||||
@@ -1396,7 +1396,7 @@ def test_set_quest_definition_active_concurrent_opposite_transitions_return_own(
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "active-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -276,7 +276,7 @@ def test_recurrence_module_imports_only_stdlib() -> None:
|
||||
from pathlib import Path
|
||||
|
||||
source = Path(
|
||||
__import__("custom_components.nestquest.recurrence",
|
||||
__import__("custom_components.nestquest.core.recurrence",
|
||||
fromlist=["__file__"]).__file__
|
||||
).read_text()
|
||||
offenders = _scan_forbidden_imports(source)
|
||||
|
||||
@@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
_month_end,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import _month_end
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
|
||||
@@ -9,10 +9,10 @@ from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
_nth_weekday_of_month,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import _nth_weekday_of_month
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
|
||||
@@ -37,7 +37,7 @@ def _make_hass_mock():
|
||||
|
||||
|
||||
def _open_db(path) -> NestQuestDatabase:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
_run(database.open(path))
|
||||
return database
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def _make_hass_mock():
|
||||
|
||||
|
||||
def _open_db(path) -> NestQuestDatabase:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
_run(database.open(path))
|
||||
return database
|
||||
|
||||
@@ -44,7 +44,7 @@ def _open_db(path) -> NestQuestDatabase:
|
||||
async def _avalid_database(path) -> None:
|
||||
"""Create a valid, migrated NestQuest database file (async, on the
|
||||
running loop, for use inside pytest-asyncio tests)."""
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -442,6 +442,11 @@ def test_package_never_unlinks_or_truncates_db_files() -> None:
|
||||
"""Guardrail: no code path may delete or overwrite an existing
|
||||
database file. Scans the package for unlink/rmtree/write on the
|
||||
DB filename constant or *.db patterns.
|
||||
|
||||
Recursive (``rglob``) so the bundled ``core/`` package (extracted
|
||||
in task d0b691d8 / 510c1f78) is scanned alongside the integration's
|
||||
own modules — a non-recursive ``glob`` skips ``core/`` and leaves
|
||||
the guardrail vacuous against it.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
@@ -459,7 +464,7 @@ def test_package_never_unlinks_or_truncates_db_files() -> None:
|
||||
re.IGNORECASE,
|
||||
)
|
||||
offenders: list[str] = []
|
||||
for py in sorted(package.glob("*.py")):
|
||||
for py in sorted(package.rglob("*.py")):
|
||||
if pattern.search(py.read_text()):
|
||||
offenders.append(py.name)
|
||||
assert offenders == [], (
|
||||
|
||||
+30
-7
@@ -23,7 +23,11 @@ def _make_hass(config_dir: Path, execute: bool = True):
|
||||
return hass
|
||||
|
||||
|
||||
def test_returns_absolute_path_from_config_path() -> None:
|
||||
def test_returns_absolute_path_from_config_path(monkeypatch) -> None:
|
||||
# Path resolution only: the parent-dir mkdir is a side effect of
|
||||
# core.store and is exercised elsewhere; suppress it here so the
|
||||
# absolute-path assertion can run against /config (non-writable).
|
||||
monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None)
|
||||
hass = _make_hass(Path("/config"), execute=False)
|
||||
db_path = _run(store.async_get_db_path(hass))
|
||||
hass.config.path.assert_called_once_with(SQLITE_DB_FILENAME)
|
||||
@@ -75,18 +79,36 @@ def test_existing_directory_does_not_fail(tmp_path) -> None:
|
||||
assert not db_path.exists()
|
||||
|
||||
|
||||
def test_mkdir_runs_via_executor(tmp_path) -> None:
|
||||
def test_mkdir_runs_off_event_loop(tmp_path, monkeypatch) -> None:
|
||||
"""The parent-directory mkdir runs off the event loop via asyncio.to_thread.
|
||||
|
||||
core.store is Home-Assistant-free, so it schedules the blocking
|
||||
``mkdir`` through :func:`asyncio.to_thread` rather than
|
||||
``hass.async_add_executor_job``; the integration shim delegates to it
|
||||
unchanged. The intent is preserved: the mkdir still runs off the
|
||||
event loop, with ``_ensure_dir`` on the parent directory, and the
|
||||
directory exists afterwards.
|
||||
"""
|
||||
nested = tmp_path / "deep" / "nested"
|
||||
hass = _make_hass(nested)
|
||||
to_thread_calls: list = []
|
||||
real_to_thread = asyncio.to_thread
|
||||
|
||||
def _trace_to_thread(fn, *args, **kwargs):
|
||||
to_thread_calls.append((fn, args, kwargs))
|
||||
return real_to_thread(fn, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(asyncio, "to_thread", _trace_to_thread)
|
||||
db_path = _run(store.async_get_db_path(hass))
|
||||
assert nested.is_dir()
|
||||
hass.async_add_executor_job.assert_called_once()
|
||||
args, kwargs = hass.async_add_executor_job.call_args
|
||||
fn, fn_args = args[0], args[1:]
|
||||
assert len(to_thread_calls) == 1, (
|
||||
f"expected one asyncio.to_thread call, got {len(to_thread_calls)}"
|
||||
)
|
||||
fn, fn_args, fn_kwargs = to_thread_calls[0]
|
||||
assert callable(fn)
|
||||
assert fn is store._ensure_dir
|
||||
assert fn_args == (nested,)
|
||||
assert kwargs == {}
|
||||
assert fn_kwargs == {}
|
||||
assert db_path.parent == nested
|
||||
assert db_path.parent.is_dir()
|
||||
assert not db_path.exists()
|
||||
@@ -180,7 +202,8 @@ def test_no_file_creation_calls_in_store() -> None:
|
||||
assert "open(" not in source
|
||||
|
||||
|
||||
def test_container_style_path_resolves() -> None:
|
||||
def test_container_style_path_resolves(monkeypatch) -> None:
|
||||
monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None)
|
||||
hass = _make_hass(Path("/config"), execute=False)
|
||||
assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME
|
||||
|
||||
|
||||
+19
-34
@@ -13,7 +13,9 @@ import datetime
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from conftest import freeze_nestquest_clock
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.core.settings import NestQuestSettings
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
@@ -38,7 +40,7 @@ def _make_hass_mock():
|
||||
|
||||
|
||||
async def _prepare(path):
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
return database
|
||||
@@ -53,35 +55,10 @@ def _freeze_clock(
|
||||
) -> tuple[datetime.datetime, list]:
|
||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||
|
||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched, so
|
||||
the generation path's "today" reads this instant (localized to whatever
|
||||
time zone it asks for) while every other module and the test harness keep
|
||||
the real clock. Returns ``(frozen, requested)`` where ``frozen`` is the
|
||||
instant normalized to UTC and ``requested`` is the list of ``tzinfo``
|
||||
objects the code under test passed to ``now`` — so a test can prove the
|
||||
generation path actually requested the configured HA time zone rather
|
||||
than silently reading UTC.
|
||||
Thin wrapper over :func:`conftest.freeze_nestquest_clock` kept for the
|
||||
``(frozen, requested)`` return shape the timezone assertions read.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
frozen = instant.astimezone(datetime.timezone.utc)
|
||||
requested: list = []
|
||||
|
||||
class _FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
requested.append(tz)
|
||||
if tz is None:
|
||||
return frozen.replace(tzinfo=None)
|
||||
return frozen.astimezone(tz)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
datetime=_FrozenDatetime,
|
||||
timedelta=datetime.timedelta,
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
return freeze_nestquest_clock(monkeypatch, instant)
|
||||
|
||||
|
||||
def _assert_requested_ha_timezone(requested, time_zone: str) -> None:
|
||||
@@ -147,7 +124,9 @@ def test_horizon_uses_newyork_local_date_after_local_midnight(
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -187,7 +166,9 @@ def test_horizon_uses_local_date_not_utc_when_host_ahead(
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -231,7 +212,9 @@ def test_horizon_resolves_spring_forward_day_without_off_by_one(
|
||||
requested_batches.append(requested)
|
||||
local_today = instant.date()
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -280,7 +263,9 @@ def test_horizon_resolves_fall_back_day_without_off_by_one(
|
||||
requested_batches.append(requested)
|
||||
local_today = instant.date()
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -313,7 +298,7 @@ def test_definition_business_regeneration_uses_pinned_today_not_host(
|
||||
business operations must regenerate against the pinned date and keep
|
||||
that first local day, proving the threaded ``today`` wins.
|
||||
"""
|
||||
import custom_components.nestquest.dao_instances as dao_instances_module
|
||||
import custom_components.nestquest.core.dao_instances as dao_instances_module
|
||||
|
||||
pinned = datetime.date(2026, 1, 14)
|
||||
host_today = datetime.date(2026, 1, 15)
|
||||
|
||||
Reference in New Issue
Block a user