diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c90e54e..5bd20da 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 53e395d..e8bd9fb 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -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, diff --git a/custom_components/nestquest/admin_allowlist.py b/custom_components/nestquest/admin_allowlist.py index beed12c..92f2bb5 100644 --- a/custom_components/nestquest/admin_allowlist.py +++ b/custom_components/nestquest/admin_allowlist.py @@ -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 diff --git a/custom_components/nestquest/children.py b/custom_components/nestquest/children.py index f4d8c3e..f936729 100644 --- a/custom_components/nestquest/children.py +++ b/custom_components/nestquest/children.py @@ -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 diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py index bb6becb..d3b10b2 100644 --- a/custom_components/nestquest/completion.py +++ b/custom_components/nestquest/completion.py @@ -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 diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index e81d78e..5b97150 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -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 diff --git a/custom_components/nestquest/core/__init__.py b/custom_components/nestquest/core/__init__.py new file mode 100644 index 0000000..89e132f --- /dev/null +++ b/custom_components/nestquest/core/__init__.py @@ -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 diff --git a/custom_components/nestquest/core/admin_allowlist.py b/custom_components/nestquest/core/admin_allowlist.py new file mode 100644 index 0000000..beed12c --- /dev/null +++ b/custom_components/nestquest/core/admin_allowlist.py @@ -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()] diff --git a/custom_components/nestquest/core/children.py b/custom_components/nestquest/core/children.py new file mode 100644 index 0000000..f4d8c3e --- /dev/null +++ b/custom_components/nestquest/core/children.py @@ -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() diff --git a/custom_components/nestquest/core/completion.py b/custom_components/nestquest/core/completion.py new file mode 100644 index 0000000..bb6becb --- /dev/null +++ b/custom_components/nestquest/core/completion.py @@ -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 diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py new file mode 100644 index 0000000..fc72aff --- /dev/null +++ b/custom_components/nestquest/core/const.py @@ -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"), +} diff --git a/custom_components/nestquest/core/dao_children.py b/custom_components/nestquest/core/dao_children.py new file mode 100644 index 0000000..57c42a8 --- /dev/null +++ b/custom_components/nestquest/core/dao_children.py @@ -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 \ No newline at end of file diff --git a/custom_components/nestquest/core/dao_instances.py b/custom_components/nestquest/core/dao_instances.py new file mode 100644 index 0000000..3d30b61 --- /dev/null +++ b/custom_components/nestquest/core/dao_instances.py @@ -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 \ No newline at end of file diff --git a/custom_components/nestquest/core/dao_meta.py b/custom_components/nestquest/core/dao_meta.py new file mode 100644 index 0000000..ada8c09 --- /dev/null +++ b/custom_components/nestquest/core/dao_meta.py @@ -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), + ) \ No newline at end of file diff --git a/custom_components/nestquest/core/dao_presence.py b/custom_components/nestquest/core/dao_presence.py new file mode 100644 index 0000000..c273287 --- /dev/null +++ b/custom_components/nestquest/core/dao_presence.py @@ -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 \ No newline at end of file diff --git a/custom_components/nestquest/core/dao_rules.py b/custom_components/nestquest/core/dao_rules.py new file mode 100644 index 0000000..ec3f6d9 --- /dev/null +++ b/custom_components/nestquest/core/dao_rules.py @@ -0,0 +1,1250 @@ +"""Typed DAO layer for the schedule_rules, quest_definitions and +quest_definition_assignees tables. + +All SQL for ``schedule_rules``, ``quest_definitions`` and +``quest_definition_assignees`` 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. + +Guardrail mapping: + +- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still + referenced by any quest definition is rejected (the done-condition), + not cascaded. +- ``quest_definitions`` has no delete path at all: definitions are + deactivated, never hard-deleted, and history must survive. +- Assignment is multi-assignee (D-008): :meth:`add_assignee` and + :meth:`remove_assignee` change future instances only — the DAO writes + the assignee table and nothing else; assignment edits never rewrite + existing instances or completion history (Feature 06 guardrail). +- Validation of rule well-formedness and child activeness lives partly + in the schema CHECKs (rule-type coherence) and partly in + :meth:`QuestDefinitionsDao.create` (rule existence) and + :meth:`QuestDefinitionsDao.add_assignee` (active child); no + permission checks here, that is the Feature 09 gate's job. + +Concurrency: ``delete_rule_if_unreferenced`` and the assignee +mutations perform their checks and writes inside one transaction under +the connection-scoped lock shared with :mod:`.dao_children`, so a +definition referencing the rule (or an assignee appearing) cannot slip +in between check and write on this connection. +""" +from __future__ import annotations + +import datetime +from dataclasses import dataclass + +from .const import QUEST_WINDOWS +from .dao_children import ChildRecord, _child_from_row, _connection_lock +from .dao_children import _CHILD_COLUMNS +from .db import NestQuestDatabase +from .recurrence import RuleType, RuleValidationError, ScheduleRule + +#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL" +#: in update methods: passing ``None`` must mean clearing a nullable +#: column, not skipping it. +_UNSET = object() + +#: Date-shape policy: schedule-rule dates are ISO YYYY-MM-DD. The +#: schema's lexical end>=start CHECK cannot prove a value is a real +#: calendar date, so the DAO validates the shape it accepts. +_DATE_FORMAT = "%Y-%m-%d" + + +def _validate_date(value: str, field: str) -> None: + """Raise ValueError unless ``value`` is a YYYY-MM-DD calendar date. + + ``datetime.date.fromisoformat`` accepts ISO dates but is lenient on + some builds (e.g. '2026-9-4'); strptime with an exact format is + strict, and strptime round-trips the parsed value to reject zero + padding ('2026-9-4' parses but its isoformat differs). + """ + try: + parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date() + except (TypeError, ValueError): + raise ValueError( + f"{field} must be an ISO date (YYYY-MM-DD), got {value!r}" + ) from None + if parsed.isoformat() != value: + raise ValueError( + f"{field} must be a strict YYYY-MM-DD date, got {value!r}" + ) + + +_TIME_FORMAT = "%H:%M" + + +def _validate_time(value: str, field: str) -> None: + """Raise ValueError unless ``value`` is a strict 24-hour HH:MM. + + Same strictness policy as _validate_date: parse with the exact + format, then round-trip so '9:30' (non-padded) is rejected. Time + shapes are caller-side policy (the schema stores TEXT): windows' + optional ``due_time`` and definition ``due_time`` both use this. + """ + try: + parsed = datetime.datetime.strptime(value, _TIME_FORMAT).time() + except (TypeError, ValueError): + raise ValueError( + f"{field} must be a 24-hour HH:MM time, got {value!r}" + ) from None + if parsed.strftime(_TIME_FORMAT) != value: + raise ValueError( + f"{field} must be a strict HH:MM time, got {value!r}" + ) + + +def _validate_window(window: str) -> None: + """Raise ValueError unless ``window`` is a ``const`` window name.""" + if window not in QUEST_WINDOWS: + raise ValueError( + f"window must be one of {', '.join(QUEST_WINDOWS)}, " + f"got {window!r}" + ) + + +@dataclass(frozen=True) +class ScheduleRuleRecord: + """One row of ``schedule_rules``.""" + + id: int + rule_type: str + interval: int + weekday_set: str | None + day_of_month: int | None + nth_weekday: int | None + month: int | None + start_date: str + end_date: str | None + + +@dataclass(frozen=True) +class ScheduleRuleStorage: + """The eight value columns a ScheduleRule maps to (no id column). + + ``id`` is assigned by the database on insert, so the model<->storage + mapping concerns only the value columns. ``nth_weekday_weekday`` is + deliberately absent here: for MONTHLY_WEEKDAY its value is folded + into ``weekday_set`` as that column's single CSV element. + """ + + rule_type: str + interval: int + weekday_set: str | None + day_of_month: int | None + nth_weekday: int | None + month: int | None + start_date: str + end_date: str | None + + +@dataclass(frozen=True) +class QuestDefinitionRecord: + """One row of ``quest_definitions`` (without its assignees).""" + + id: int + title: str + description: str | None + icon: str | None + schedule_rule_id: int + due_time: str | None + is_active: bool + created_at: str + + +@dataclass(frozen=True) +class QuestDefinitionWindowRecord: + """One row of ``quest_definition_windows``.""" + + definition_id: int + window: str + due_time: str | None + + +@dataclass(frozen=True) +class QuestDefinitionSnapshot: + """A definition plus its rule, assignees and windows, read atomically. + + Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows` + and :meth:`QuestDefinitionsDao.edit_definition` with the rule, + assignees and windows read inside the SAME transaction that wrote + them, so the snapshot is a consistent creation/edit-time view — a + concurrent rule, assignee or window mutation cannot change it + between the write and the read. + """ + + definition: QuestDefinitionRecord + rule: ScheduleRuleRecord + assignees: list[ChildRecord] + windows: list[QuestDefinitionWindowRecord] + + +_RULE_COLUMNS = ( + "id, rule_type, interval, weekday_set, day_of_month, nth_weekday, " + "month, start_date, end_date" +) +_DEFINITION_COLUMNS = ( + "id, title, description, icon, schedule_rule_id, due_time, " + "is_active, created_at" +) +_WINDOW_COLUMNS = "definition_id, window, due_time" + + +def _rule_from_row(row: tuple) -> ScheduleRuleRecord: + return ScheduleRuleRecord( + id=row[0], + rule_type=row[1], + interval=row[2], + weekday_set=row[3], + day_of_month=row[4], + nth_weekday=row[5], + month=row[6], + start_date=row[7], + end_date=row[8], + ) + + +def _definition_from_row(row: tuple) -> QuestDefinitionRecord: + return QuestDefinitionRecord( + id=row[0], + title=row[1], + description=row[2], + icon=row[3], + schedule_rule_id=row[4], + due_time=row[5], + is_active=bool(row[6]), + created_at=row[7], + ) + + +def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord: + return QuestDefinitionWindowRecord( + definition_id=row[0], + window=row[1], + due_time=row[2], + ) + + +#: Storage columns that are forbidden for each rule type. A populated +#: forbidden column means the row's shape is invalid and must raise on +#: read, never be silently dropped. ``weekday_set`` is absent for +#: DAILY/MONTHLY_DAY/YEARLY; MONTHLY_WEEKDAY is the only shape allowed a +#: non-NULL ``nth_weekday``; YEARLY is the only shape allowed ``month``. +_FORBIDDEN_STORAGE_FIELDS: dict[str, tuple[RuleType, ...]] = { + "weekday_set": (RuleType.DAILY, RuleType.MONTHLY_DAY, RuleType.YEARLY), + "day_of_month": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_WEEKDAY, + ), + "nth_weekday": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, + RuleType.YEARLY, + ), + "month": ( + RuleType.DAILY, + RuleType.WEEKLY, + RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, + RuleType.MONTHLY_WEEKDAY, + ), +} + + +def schedule_rule_to_storage(rule: ScheduleRule) -> ScheduleRuleStorage: + """Map a validated ScheduleRule onto its storage columns. + + ``rule_type`` stores as ``RuleType.storage_value`` (MONTHLY_DAY and + MONTHLY_WEEKDAY both store ``'monthly'``); ``weekday_set`` stores as + a sorted CSV list. MONTHLY_WEEKDAY folds its model-only + ``nth_weekday_weekday`` into ``weekday_set`` as that list's single + element; every other type leaves the column as its own set + (WEEKLY/CUSTOM_DAYS) or NULL. + """ + shape = rule.rule_type + if shape is RuleType.MONTHLY_WEEKDAY: + weekday_set = str(rule.nth_weekday_weekday) + elif shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: + weekday_set = ",".join(str(w) for w in sorted(rule.weekday_set)) + else: + weekday_set = None + return ScheduleRuleStorage( + rule_type=shape.storage_value, + interval=rule.interval, + weekday_set=weekday_set, + day_of_month=rule.day_of_month, + nth_weekday=rule.nth_weekday, + month=rule.month, + start_date=rule.start_date, + end_date=rule.end_date, + ) + + +def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule: + """Reconstruct a ScheduleRule from storage columns. + + Disambiguates MONTHLY_DAY from MONTHLY_WEEKDAY by which field is + populated, and raises RuleValidationError rather than silently + mis-decoding an ambiguous or invalid shape. + """ + rule_type = storage.rule_type + if rule_type == "daily": + shape = RuleType.DAILY + elif rule_type == "weekly": + shape = RuleType.WEEKLY + elif rule_type == "monthly": + shape = _disambiguate_monthly(storage) + elif rule_type == "yearly": + shape = RuleType.YEARLY + elif rule_type == "custom": + shape = RuleType.CUSTOM_DAYS + else: + raise RuleValidationError( + f"unknown storage rule_type {rule_type!r}" + ) + + _reject_forbidden_storage_fields(storage, shape) + + weekday_set = None + nth_weekday_weekday = None + if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: + weekday_set = _weekdays_from_csv(storage.weekday_set) + elif shape is RuleType.MONTHLY_WEEKDAY: + nth_weekday_weekday = _single_weekday_from_csv(storage.weekday_set) + + return ScheduleRule( + rule_type=shape, + interval=storage.interval, + weekday_set=weekday_set, + day_of_month=storage.day_of_month, + nth_weekday=storage.nth_weekday, + nth_weekday_weekday=nth_weekday_weekday, + month=storage.month, + start_date=storage.start_date, + end_date=storage.end_date, + ) + + +def schedule_rule_storage_from_record( + record: ScheduleRuleRecord, +) -> ScheduleRuleStorage: + """Copy a rule row's value columns into a storage mapping. + + ``ScheduleRuleRecord`` is the read shape (it carries ``id``); + :func:`schedule_rule_from_storage` consumes the value-only + :class:`ScheduleRuleStorage` shape, so callers re-reading a stored + rule convert through this helper. + """ + return ScheduleRuleStorage( + rule_type=record.rule_type, + interval=record.interval, + weekday_set=record.weekday_set, + day_of_month=record.day_of_month, + nth_weekday=record.nth_weekday, + month=record.month, + start_date=record.start_date, + end_date=record.end_date, + ) + + +def _reject_forbidden_storage_fields( + storage: ScheduleRuleStorage, shape: RuleType +) -> None: + """Raise unless every populated column is permitted for ``shape``. + + The model constructor rejects a forbidden ``day_of_month``, + ``nth_weekday``, or ``month``, but ``weekday_set`` never reaches it + (it is folded for MONTHLY_WEEKDAY and dropped otherwise), so a daily + row carrying ``weekday_set="0"`` would otherwise decode as a valid + daily rule and silently lose the stored data. This validates every + shape column explicitly so a populated-forbidden column raises + instead of being discarded. + """ + for field, forbidden_shapes in _FORBIDDEN_STORAGE_FIELDS.items(): + value = getattr(storage, field) + if value is not None and shape in forbidden_shapes: + raise RuleValidationError( + f"{shape.value} storage rows must not set {field}, " + f"got {value!r}" + ) + + +def _disambiguate_monthly(storage: ScheduleRuleStorage) -> RuleType: + """Resolve the shared 'monthly' storage value to a model type. + + MONTHLY_DAY names ``day_of_month`` only; MONTHLY_WEEKDAY names + ``nth_weekday`` only. Neither, or both, is ambiguous and raises. + """ + day_of_month = storage.day_of_month + nth_weekday = storage.nth_weekday + if day_of_month is not None and nth_weekday is None: + return RuleType.MONTHLY_DAY + if nth_weekday is not None and day_of_month is None: + return RuleType.MONTHLY_WEEKDAY + raise RuleValidationError( + "storage rule_type 'monthly' is ambiguous: must name exactly one " + "of day_of_month or nth_weekday, got " + f"day_of_month={day_of_month!r}, nth_weekday={nth_weekday!r}" + ) + + +def _weekdays_from_csv(value: str | None) -> frozenset[int]: + """Parse a weekday CSV column into a frozenset of ints. + + ``None`` and ``""`` both yield an empty frozenset (the caller decides + whether empty is legal); a non-integer token raises + RuleValidationError rather than leaking a raw ValueError. + """ + if value is None or value == "": + return frozenset() + try: + entries = [int(token) for token in value.split(",")] + except ValueError: + raise RuleValidationError( + f"weekday_set must be a CSV of integers, got {value!r}" + ) from None + return frozenset(entries) + + +def _single_weekday_from_csv(value: str | None) -> int: + """The one weekday a MONTHLY_WEEKDAY stores in weekday_set. + + Parses the CSV into tokens FIRST and requires exactly one token, + so a duplicate pair like ``"1,1"`` is rejected rather than silently + canonicalized to a single weekday on re-storage. An empty or + multi-element column raises RuleValidationError. + """ + if value is None or value == "": + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " + f"weekday, got {value!r}" + ) + tokens = value.split(",") + if len(tokens) != 1: + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " + f"weekday, got {value!r}" + ) + try: + return int(tokens[0]) + except ValueError: + raise RuleValidationError( + "MONTHLY_WEEKDAY storage weekday_set must be an integer, " + f"got {value!r}" + ) from None + + +async def _insert_rule_row( + database: NestQuestDatabase, storage: ScheduleRuleStorage +) -> int: + """Insert one ``schedule_rules`` row and return its new id. + + Runs inside the caller's transaction (no lock or transaction of its + own) so a multi-table write can persist the rule together with the + rows that reference it. The caller is responsible for validating + the storage fields — :meth:`ScheduleRulesDao.create` validates the + date shape, and :meth:`QuestDefinitionsDao.create_with_rule_and_windows` + receives storage already mapped from a validated + :class:`~.recurrence.ScheduleRule`. + """ + result = await database.execute( + "INSERT INTO schedule_rules (rule_type, interval, weekday_set, " + "day_of_month, nth_weekday, month, start_date, end_date) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + storage.rule_type, + storage.interval, + storage.weekday_set, + storage.day_of_month, + storage.nth_weekday, + storage.month, + storage.start_date, + storage.end_date, + ), + ) + return result.lastrowid + + +class ScheduleRulesDao: + """Typed async access to the ``schedule_rules`` table.""" + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def create( + self, + rule_type: str, + start_date: str, + *, + interval: int = 1, + weekday_set: str | None = None, + day_of_month: int | None = None, + nth_weekday: int | None = None, + month: int | None = None, + end_date: str | None = None, + ) -> ScheduleRuleRecord: + """Insert one rule and return the record as stored. + + Rule-type coherence (a weekly rule needs ``weekday_set``, a + yearly rule needs ``month``, etc.) is enforced by the schema + CHECKs and surfaces as :class:`sqlite3.IntegrityError`. Date + shape (strict YYYY-MM-DD) is validated here because the + schema's lexical end>=start CHECK cannot prove a real calendar + date. + """ + _validate_date(start_date, "start_date") + if end_date is not None: + _validate_date(end_date, "end_date") + rule_id = await _insert_rule_row( + self._database, + ScheduleRuleStorage( + rule_type=rule_type, + interval=interval, + weekday_set=weekday_set, + day_of_month=day_of_month, + nth_weekday=nth_weekday, + month=month, + start_date=start_date, + end_date=end_date, + ), + ) + rule = await self.get(rule_id) + assert rule is not None + return rule + + async def get(self, rule_id: int) -> ScheduleRuleRecord | None: + """Return the rule with ``rule_id``, or None.""" + row = await self._database.fetch_one( + f"SELECT {_RULE_COLUMNS} FROM schedule_rules WHERE id = ?", + (rule_id,), + ) + return _rule_from_row(row) if row is not None else None + + async def update( + self, + rule_id: int, + *, + rule_type: str | None | object = _UNSET, + interval: int | None | object = _UNSET, + weekday_set: str | None | object = _UNSET, + day_of_month: int | None | object = _UNSET, + nth_weekday: int | None | object = _UNSET, + month: int | None | object = _UNSET, + start_date: str | None | object = _UNSET, + end_date: str | None | object = _UNSET, + ) -> int: + """Update the given fields; returns rows updated (0 if absent). + + Arguments default to the module sentinel ``_UNSET`` meaning + "leave this column alone"; passing ``None`` explicitly writes + SQL NULL, so callers can reopen an ended rule (``end_date`` + back to NULL) or clear nullable shape fields. Passing + ``None``/a new value for a date field validates its shape. + Editing a rule that a definition references changes future + materializations only — this method touches no instances + (Feature 06/07 separation). + """ + date_args = ( + ("start_date", start_date), + ("end_date", end_date), + ) + for field, value in date_args: + if value is not _UNSET and value is not None: + _validate_date(value, field) + assignments: list[str] = [] + parameters: list[object] = [] + for column, value in ( + ("rule_type", rule_type), + ("interval", interval), + ("weekday_set", weekday_set), + ("day_of_month", day_of_month), + ("nth_weekday", nth_weekday), + ("month", month), + ("start_date", start_date), + ("end_date", end_date), + ): + if value is not _UNSET: + assignments.append(f"{column} = ?") + parameters.append(value) + if not assignments: + return 0 + parameters.append(rule_id) + result = await self._database.execute( + f"UPDATE schedule_rules SET {', '.join(assignments)} " + "WHERE id = ?", + tuple(parameters), + ) + return result.rowcount + + async def delete_if_unreferenced(self, rule_id: int) -> bool: + """Delete the rule only if no definition references it. + + Returns True if the rule was deleted, False if it is still + referenced (or does not exist — distinguish via :meth:`get`). + The reference check and the DELETE run inside one transaction + under the connection lock, so a definition cannot start + referencing the rule between the check and the delete. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + row = await self._database.fetch_one( + "SELECT 1 FROM quest_definitions " + "WHERE schedule_rule_id = ? LIMIT 1", + (rule_id,), + ) + if row is not None: + return False + result = await self._database.execute( + "DELETE FROM schedule_rules WHERE id = ?", + (rule_id,), + ) + return result.rowcount > 0 + + +class QuestDefinitionsDao: + """Typed async access to ``quest_definitions`` and its assignees. + + No delete method exists: definitions are deactivated via + :meth:`set_active`, never hard-deleted, so completion history keeps + its references (feature guardrail). Assignment is multi-assignee + (D-008): the definition row carries no child; assignees live in + ``quest_definition_assignees`` and are managed explicitly so the + operations stay separately loggable and permission-gateable. + """ + + def __init__(self, database: NestQuestDatabase) -> None: + self._database = database + + async def create( + self, + title: str, + schedule_rule_id: int, + created_at: str, + *, + description: str | None = None, + icon: str | None = None, + due_time: str | None = None, + is_active: bool = True, + assignee_child_ids: list[int] | None = None, + ) -> QuestDefinitionRecord: + """Insert one definition and return the record as stored. + + Validates the schedule rule as part of the same serialized + transaction as the insert: the rule must exist AT INSERT TIME — + a rule deleted concurrently cannot slip between the check and + the INSERT. ``assignee_child_ids`` (each child must exist and + be active) is applied in the same transaction, so a definition + can never be observed with a stale or missing assignee set; an + empty list or None creates an unassigned definition, which + callers may fill via :meth:`add_assignee`. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + await self._validate_rule_exists(schedule_rule_id) + for child_id in assignee_child_ids or []: + await self._validate_assignable_child(child_id) + result = await self._database.execute( + "INSERT INTO quest_definitions (title, description, " + "icon, schedule_rule_id, due_time, is_active, " + "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + title, + description, + icon, + schedule_rule_id, + due_time, + int(is_active), + created_at, + ), + ) + definition_id = result.lastrowid + for child_id in assignee_child_ids or []: + await self._database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?)", + (definition_id, child_id), + ) + definition = await self.get(definition_id) + assert definition is not None + return definition + + async def create_with_rule_and_windows( + self, + title: str, + schedule_rule: ScheduleRuleStorage, + created_at: str, + assignee_child_ids: list[int], + windows: list[tuple[str, str | None]], + *, + description: str | None = None, + icon: str | None = None, + ) -> QuestDefinitionSnapshot: + """Insert a rule, definition, assignees and windows atomically. + + The whole write — the schedule rule row, the definition row, + every assignee link and every window declaration — runs inside + ONE transaction under the connection lock, so a validation or + write failure rolls the lot back together (all-or-nothing): the + caller can never observe a definition with a missing rule, + assignee or window, and a rejected create leaves no partial + rows. + + Returns a :class:`QuestDefinitionSnapshot` whose assignees and + windows are read back inside the same transaction, so the + returned view cannot be raced by a concurrent assignee/window + mutation after the write. + + The rule arrives as pre-validated storage fields (mapped from a + :class:`~.recurrence.ScheduleRule` via + :func:`schedule_rule_to_storage`); its dates are re-validated + here so a hand-built storage can never persist a malformed date. + The definition-level ``due_time`` column is deliberately left + NULL — per-window due times supersede it (D-008). Assignees are + validated as existing-and-active at insert time, and windows are + validated for name and strict HH:MM due time, matching + :meth:`upsert_window`. + """ + _validate_date(schedule_rule.start_date, "start_date") + if schedule_rule.end_date is not None: + _validate_date(schedule_rule.end_date, "end_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + for child_id in assignee_child_ids: + await self._validate_assignable_child(child_id) + rule_id = await _insert_rule_row(self._database, schedule_rule) + result = await self._database.execute( + "INSERT INTO quest_definitions (title, description, " + "icon, schedule_rule_id, due_time, is_active, " + "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (title, description, icon, rule_id, None, 1, created_at), + ) + definition_id = result.lastrowid + for child_id in assignee_child_ids: + await self._database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?)", + (definition_id, child_id), + ) + for window, due_time in windows: + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, ?)", + (definition_id, window, due_time), + ) + definition = await self.get(definition_id) + assignees = await self.list_assignees(definition_id) + window_records = await self.list_windows(definition_id) + rule_record = await ScheduleRulesDao(self._database).get( + rule_id + ) + assert definition is not None + assert rule_record is not None + return QuestDefinitionSnapshot( + definition=definition, + rule=rule_record, + assignees=assignees, + windows=window_records, + ) + + async def edit_definition( + self, + definition_id: int, + *, + title: str | None | object = _UNSET, + description: str | None | object = _UNSET, + icon: str | None | object = _UNSET, + rule: ScheduleRuleStorage | object = _UNSET, + windows: list[tuple[str, str | None]] | object = _UNSET, + ) -> QuestDefinitionSnapshot: + """Edit a definition's metadata, rule and windows atomically. + + Mirrors :meth:`create_with_rule_and_windows`: everything runs + inside ONE transaction under the connection lock, and the + returned snapshot's rule, assignees and windows are read back + inside that transaction. Arguments default to the module sentinel + ``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL + NULL so optional metadata (description, icon) can be cleared. + + - ``title``/``description``/``icon`` update the definition row + in place; the definition-level ``due_time`` column is never + written (per-window due times supersede it, D-008). + - ``rule`` (a pre-validated :class:`ScheduleRuleStorage`) + overwrites the referenced ``schedule_rules`` row in place: the + definition keeps its ``schedule_rule_id``, so no new or + orphaned rule row appears. + - ``windows`` REPLACES the whole window set: the current rows + are deleted and the given set re-inserted, so missing windows + are added, changed due times updated and removed windows + dropped. + + Assignment is deliberately not settable here (multi-assignee is + managed by :meth:`add_assignee`/:meth:`remove_assignee`), and + activation is untouched. Raises ValueError when the definition + does not exist. Edits change future instances only: no + ``quest_instances`` or ``completion_events`` row is touched. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definition = await self.get(definition_id) + if definition is None: + raise ValueError( + f"quest definition {definition_id} does not exist" + ) + assignments: list[str] = [] + parameters: list[object] = [] + for column, value in ( + ("title", title), + ("description", description), + ("icon", icon), + ): + if value is not _UNSET: + assignments.append(f"{column} = ?") + parameters.append(value) + if assignments: + parameters.append(definition_id) + await self._database.execute( + f"UPDATE quest_definitions SET " + f"{', '.join(assignments)} WHERE id = ?", + tuple(parameters), + ) + if rule is not _UNSET: + await ScheduleRulesDao(self._database).update( + definition.schedule_rule_id, + rule_type=rule.rule_type, + interval=rule.interval, + weekday_set=rule.weekday_set, + day_of_month=rule.day_of_month, + nth_weekday=rule.nth_weekday, + month=rule.month, + start_date=rule.start_date, + end_date=rule.end_date, + ) + if windows is not _UNSET: + await self._database.execute( + "DELETE FROM quest_definition_windows " + "WHERE definition_id = ?", + (definition_id,), + ) + for window, due_time in windows: + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) " + "VALUES (?, ?, ?)", + (definition_id, window, due_time), + ) + updated = await self.get(definition_id) + assignees = await self.list_assignees(definition_id) + window_records = await self.list_windows(definition_id) + rule_record = await ScheduleRulesDao(self._database).get( + updated.schedule_rule_id + ) + assert updated is not None + assert rule_record is not None + return QuestDefinitionSnapshot( + definition=updated, + rule=rule_record, + assignees=assignees, + windows=window_records, + ) + + async def _validate_rule_exists(self, schedule_rule_id: int) -> None: + """Raise ValueError unless the rule exists. + + MUST be called inside the connection lock so the verdict cannot + go stale before the caller's INSERT commits. + """ + rule = await self._database.fetch_one( + "SELECT 1 FROM schedule_rules WHERE id = ?", + (schedule_rule_id,), + ) + if rule is None: + raise ValueError( + f"schedule rule {schedule_rule_id} does not exist" + ) + + async def get(self, definition_id: int) -> QuestDefinitionRecord | None: + """Return the definition with ``definition_id``, or None.""" + row = await self._database.fetch_one( + f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " + "WHERE id = ?", + (definition_id,), + ) + return _definition_from_row(row) if row is not None else None + + async def list_by_child(self, child_id: int) -> list[QuestDefinitionRecord]: + """Return all definitions assigned to ``child_id``, newest first. + + Assignment is read through ``quest_definition_assignees`` + (D-008): a definition appears once per child regardless of how + many assignees share it. + """ + rows = await self._database.fetch_all( + f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " + "WHERE id IN (SELECT definition_id " + "FROM quest_definition_assignees WHERE child_id = ?) " + "ORDER BY id DESC", + (child_id,), + ) + return [_definition_from_row(row) for row in rows] + + async def list_active(self) -> list[QuestDefinitionRecord]: + """Return all active definitions, oldest first (stable order).""" + rows = await self._database.fetch_all( + f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " + "WHERE is_active = 1 ORDER BY id" + ) + return [_definition_from_row(row) for row in rows] + + async def _snapshots_for( + self, definitions: list[QuestDefinitionRecord] + ) -> list[QuestDefinitionSnapshot]: + """Read each definition's rule, assignees and windows. + + MUST be called inside the connection lock (and ideally a + transaction) so the definitions handed in and the rows this + reads back cannot be raced by a concurrent edit between list + and bundle. Each rule is read through + :class:`ScheduleRulesDao` on the same connection, so the + snapshot's rule record is the one the definition references AT + the moment of the locked read. + """ + rules_dao = ScheduleRulesDao(self._database) + snapshots: list[QuestDefinitionSnapshot] = [] + for definition in definitions: + rule_record = await rules_dao.get(definition.schedule_rule_id) + assert rule_record is not None + snapshots.append( + QuestDefinitionSnapshot( + definition=definition, + rule=rule_record, + assignees=await self.list_assignees(definition.id), + windows=await self.list_windows(definition.id), + ) + ) + return snapshots + + async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]: + """Return every active definition with its rule, assignees and + windows, all read inside one locked transaction. + + The definitive list and each definition's linked rows are read + in a single BEGIN..COMMIT span under the connection lock, so a + concurrent atomic edit cannot interleave between the definition + list and the bundle read: the caller receives a coherent + snapshot (each definition's rule, assignees and windows as they + were at one instant), never a mixed old-rule/new-windows state. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definitions = await self.list_active() + return await self._snapshots_for(definitions) + + async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]: + """Return every active definition's snapshot WITHOUT locking. + + Mirrors :meth:`list_snapshots_active` minus the lock and + transaction: the definitions and their rules, assignees and + windows are read on the caller's connection under whatever + transaction the caller is already holding, so they stay coherent + with every OTHER table the caller reads inside that same + transaction. MUST be called inside the connection lock and an + open transaction (the single-transaction input snapshot the + materialization walk uses is that caller). + """ + definitions = await self.list_active() + return await self._snapshots_for(definitions) + + async def list_snapshots_by_child( + self, child_id: int + ) -> list[QuestDefinitionSnapshot]: + """Return each definition assigned to ``child_id`` with its rule, + assignees and windows, all read inside one locked transaction. + + Mirrors :meth:`list_snapshots_active` but selects by assignment + (``list_by_child`` order: newest first). Locks read-and-bundle + into one transaction so the bundle never mixes one edit's + definition with another's windows. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + definitions = await self.list_by_child(child_id) + return await self._snapshots_for(definitions) + + async def update( + self, + definition_id: int, + *, + title: str | None | object = _UNSET, + description: str | None | object = _UNSET, + icon: str | None | object = _UNSET, + due_time: str | None | object = _UNSET, + ) -> int: + """Update the given fields; returns rows updated (0 if absent). + + Arguments default to the module sentinel ``_UNSET`` meaning + "leave this column alone"; passing ``None`` explicitly writes + SQL NULL, so callers can remove optional metadata + (description, icon, due_time). Assignment and activation are + deliberately NOT settable here: they have their own explicit + operations (:meth:`add_assignee`, :meth:`remove_assignee`, + :meth:`set_active`) so they stay separately loggable and + permission-gateable. + """ + assignments: list[str] = [] + parameters: list[object] = [] + for column, value in ( + ("title", title), + ("description", description), + ("icon", icon), + ("due_time", due_time), + ): + if value is not _UNSET: + assignments.append(f"{column} = ?") + parameters.append(value) + if not assignments: + return 0 + parameters.append(definition_id) + result = await self._database.execute( + f"UPDATE quest_definitions SET {', '.join(assignments)} " + "WHERE id = ?", + tuple(parameters), + ) + return result.rowcount + + async def set_active(self, definition_id: int, is_active: bool) -> int: + """Deactivate/reactivate; returns rows updated (0 if absent). + + Deactivating stops future instance generation but leaves + existing instances and completion history untouched. + """ + result = await self._database.execute( + "UPDATE quest_definitions SET is_active = ? WHERE id = ?", + (int(is_active), definition_id), + ) + return result.rowcount + + async def add_assignee( + self, definition_id: int, child_id: int + ) -> list[ChildRecord]: + """Assign ``child_id`` to the definition (idempotent). + + Validates the assignee inside the same serialized transaction + as the INSERT (existing and active AT ASSIGNMENT TIME), so a + child deactivated concurrently cannot become an assignee. + Assigning an already-assigned child is a no-op, mirroring the + composite primary key's storage-level guarantee. Returns the + definition's assignees read back INSIDE the same transaction + that performed the write, so the returned roster is a + consistent post-assignment snapshot — a concurrent assign or + unassign cannot race it after the write commits. Changes + future instances only: already-generated instances and + completion history are never rewritten here. + """ + async with _connection_lock(self._database): + async with self._database.transaction(): + await self._validate_assignable_child(child_id) + 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" + ) + await self._database.execute( + "INSERT INTO quest_definition_assignees " + "(definition_id, child_id) VALUES (?, ?) " + "ON CONFLICT (definition_id, child_id) DO NOTHING", + (definition_id, child_id), + ) + return await self.list_assignees(definition_id) + + async def remove_assignee( + self, definition_id: int, child_id: int + ) -> bool: + """Remove the assignee link; True when a row was removed. + + Future materialization stops for this (definition, child) pair; + existing instances and completion history are never touched + here (Feature 06 guardrail). + """ + async with _connection_lock(self._database): + result = await self._database.execute( + "DELETE FROM quest_definition_assignees " + "WHERE definition_id = ? AND child_id = ?", + (definition_id, child_id), + ) + return result.rowcount > 0 + + async def list_assignees(self, definition_id: int) -> list[ChildRecord]: + """Return the definition's assignees as child records. + + Ordered by the children table's stable display order + (``sort_order``, then id) so callers render a deterministic + roster. A definition with no assignees returns []. + """ + rows = await self._database.fetch_all( + f"SELECT {_CHILD_COLUMNS} FROM children " + "WHERE id IN (SELECT child_id " + "FROM quest_definition_assignees WHERE definition_id = ?) " + "ORDER BY sort_order, id", + (definition_id,), + ) + return [_child_from_row(row) for row in rows] + + async def upsert_window( + self, + definition_id: int, + window: str, + *, + due_time: str | None = None, + ) -> QuestDefinitionWindowRecord: + """Declare (or re-declare) ``window`` on the definition. + + The composite primary key makes the write idempotent per + (definition, window): re-declaring updates ``due_time`` in + place instead of duplicating. The window name must be one of + ``const.QUEST_WINDOWS`` (the schema CHECK backs this up) and a + provided ``due_time`` must be strict 24-hour HH:MM. The + definition must exist — validated under the connection lock so + the verdict cannot go stale before the write. + """ + _validate_window(window) + if due_time is not None: + _validate_time(due_time, "due_time") + async with _connection_lock(self._database): + async with self._database.transaction(): + 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" + ) + await self._database.execute( + "INSERT INTO quest_definition_windows " + "(definition_id, window, due_time) VALUES (?, ?, ?) " + "ON CONFLICT (definition_id, window) DO UPDATE SET " + "due_time = excluded.due_time", + (definition_id, window, due_time), + ) + row = await self._database.fetch_one( + f"SELECT {_WINDOW_COLUMNS} " + "FROM quest_definition_windows " + "WHERE definition_id = ? AND window = ?", + (definition_id, window), + ) + assert row is not None + return _window_from_row(row) + + async def list_windows( + self, definition_id: int + ) -> list[QuestDefinitionWindowRecord]: + """Return the definition's windows in ``const`` order. + + Ordered by the canonical window sequence (morning, afternoon, + evening) rather than insertion order, so callers always render + the Quest Log's three columns consistently. A definition with + no windows returns []. + """ + rows = await self._database.fetch_all( + f"SELECT {_WINDOW_COLUMNS} FROM quest_definition_windows " + "WHERE definition_id = ?", + (definition_id,), + ) + by_name = {row[1]: _window_from_row(row) for row in rows} + return [ + by_name[name] + for name in QUEST_WINDOWS + if name in by_name + ] + + async def remove_window( + self, definition_id: int, window: str + ) -> bool: + """Remove the window declaration; True when a row was removed. + + Future materialization stops producing instances for this + (definition, window); existing instances and completion history + are never touched here (Feature 06 guardrail). + """ + _validate_window(window) + async with _connection_lock(self._database): + result = await self._database.execute( + "DELETE FROM quest_definition_windows " + "WHERE definition_id = ? AND window = ?", + (definition_id, window), + ) + return result.rowcount > 0 + + async def _validate_assignable_child(self, child_id: int) -> None: + """Raise ValueError unless the child exists and is active. + + MUST be called inside the connection lock so the verdict cannot + go stale before the caller's INSERT commits. + """ + child = await self._database.fetch_one( + "SELECT is_active FROM children WHERE id = ?", (child_id,) + ) + if child is None: + raise ValueError(f"child {child_id} does not exist") + if not child[0]: + raise ValueError( + f"child {child_id} is inactive and cannot be assigned" + ) + + +async def load_materialization_input( + database: NestQuestDatabase, + range_start: str, + range_end: str, +): + """Read every materialization input in ONE locked transaction. + + Returns ``(snapshots, schedules, overrides)``: + + - ``snapshots``: every active definition with its rule, assignees and + windows (the :class:`QuestDefinitionSnapshot` shape). + - ``schedules`` / ``overrides``: the presence schedules and + range-scoped overrides of every assignee child, in + :mod:`.dao_presence`'s record shapes. + + The whole read — definitions, rules, assignees, windows, and the + assignees' presence schedules and overrides — runs inside a single + BEGIN..COMMIT span under the connection lock, so a concurrent edit + (definition, assignment, window, or presence) cannot interleave + between the parts and the walk never sees a mixed-time view. Each + table's SQL stays in its own DAO module: the presence portion is + delegated to :mod:`.dao_presence` (a lazy import here, since that + module imports this one). + """ + definitions_dao = QuestDefinitionsDao(database) + async with _connection_lock(database): + async with database.transaction(): + snapshots = await definitions_dao.read_snapshots_active() + child_ids = sorted( + { + child.id + for snapshot in snapshots + for child in snapshot.assignees + } + ) + from . import dao_presence # lazy: dao_presence imports us + + schedules, overrides = await dao_presence.read_snapshot_unlocked( + database, child_ids, range_start, range_end + ) + return snapshots, schedules, overrides \ No newline at end of file diff --git a/custom_components/nestquest/core/db.py b/custom_components/nestquest/core/db.py new file mode 100644 index 0000000..f72d7ec --- /dev/null +++ b/custom_components/nestquest/core/db.py @@ -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 \ No newline at end of file diff --git a/custom_components/nestquest/core/events.py b/custom_components/nestquest/core/events.py new file mode 100644 index 0000000..01e7015 --- /dev/null +++ b/custom_components/nestquest/core/events.py @@ -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)] diff --git a/custom_components/nestquest/core/materialize.py b/custom_components/nestquest/core/materialize.py new file mode 100644 index 0000000..c32d3d9 --- /dev/null +++ b/custom_components/nestquest/core/materialize.py @@ -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 + ) diff --git a/custom_components/nestquest/core/migrations.py b/custom_components/nestquest/core/migrations.py new file mode 100644 index 0000000..cf40a7a --- /dev/null +++ b/custom_components/nestquest/core/migrations.py @@ -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 \ No newline at end of file diff --git a/custom_components/nestquest/core/presence.py b/custom_components/nestquest/core/presence.py new file mode 100644 index 0000000..465c105 --- /dev/null +++ b/custom_components/nestquest/core/presence.py @@ -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" + ) diff --git a/custom_components/nestquest/core/quest_definitions.py b/custom_components/nestquest/core/quest_definitions.py new file mode 100644 index 0000000..d301aa4 --- /dev/null +++ b/custom_components/nestquest/core/quest_definitions.py @@ -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)] diff --git a/custom_components/nestquest/core/recurrence.py b/custom_components/nestquest/core/recurrence.py new file mode 100644 index 0000000..c114568 --- /dev/null +++ b/custom_components/nestquest/core/recurrence.py @@ -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 diff --git a/custom_components/nestquest/core/schema.py b/custom_components/nestquest/core/schema.py new file mode 100644 index 0000000..b3c3223 --- /dev/null +++ b/custom_components/nestquest/core/schema.py @@ -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) \ No newline at end of file diff --git a/custom_components/nestquest/core/service_policy.py b/custom_components/nestquest/core/service_policy.py new file mode 100644 index 0000000..823bed4 --- /dev/null +++ b/custom_components/nestquest/core/service_policy.py @@ -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)}" + ) diff --git a/custom_components/nestquest/core/settings.py b/custom_components/nestquest/core/settings.py new file mode 100644 index 0000000..c432335 --- /dev/null +++ b/custom_components/nestquest/core/settings.py @@ -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) diff --git a/custom_components/nestquest/core/store.py b/custom_components/nestquest/core/store.py new file mode 100644 index 0000000..0843d70 --- /dev/null +++ b/custom_components/nestquest/core/store.py @@ -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 diff --git a/custom_components/nestquest/dao_children.py b/custom_components/nestquest/dao_children.py index 57c42a8..6ee1e2e 100644 --- a/custom_components/nestquest/dao_children.py +++ b/custom_components/nestquest/dao_children.py @@ -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 \ No newline at end of file +from .core.dao_children import * # noqa: F401,F403 +from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401 diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 3d30b61..1ac0c9e 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -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 \ No newline at end of file +from .core.dao_instances import * # noqa: F401,F403 +from .core.dao_instances import _resolve_today # noqa: F401 diff --git a/custom_components/nestquest/dao_meta.py b/custom_components/nestquest/dao_meta.py index ada8c09..bef9c16 100644 --- a/custom_components/nestquest/dao_meta.py +++ b/custom_components/nestquest/dao_meta.py @@ -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), - ) \ No newline at end of file +from .core.dao_meta import * # noqa: F401,F403 diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index c273287..422ae17 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -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 \ No newline at end of file +from .core.dao_presence import * # noqa: F401,F403 diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py index ec3f6d9..31c4ab9 100644 --- a/custom_components/nestquest/dao_rules.py +++ b/custom_components/nestquest/dao_rules.py @@ -1,1250 +1,17 @@ -"""Typed DAO layer for the schedule_rules, quest_definitions and -quest_definition_assignees tables. +"""Re-export of :mod:`custom_components.nestquest.core.dao_rules` for the integration package. -All SQL for ``schedule_rules``, ``quest_definitions`` and -``quest_definition_assignees`` 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. - -Guardrail mapping: - -- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still - referenced by any quest definition is rejected (the done-condition), - not cascaded. -- ``quest_definitions`` has no delete path at all: definitions are - deactivated, never hard-deleted, and history must survive. -- Assignment is multi-assignee (D-008): :meth:`add_assignee` and - :meth:`remove_assignee` change future instances only — the DAO writes - the assignee table and nothing else; assignment edits never rewrite - existing instances or completion history (Feature 06 guardrail). -- Validation of rule well-formedness and child activeness lives partly - in the schema CHECKs (rule-type coherence) and partly in - :meth:`QuestDefinitionsDao.create` (rule existence) and - :meth:`QuestDefinitionsDao.add_assignee` (active child); no - permission checks here, that is the Feature 09 gate's job. - -Concurrency: ``delete_rule_if_unreferenced`` and the assignee -mutations perform their checks and writes inside one transaction under -the connection-scoped lock shared with :mod:`.dao_children`, so a -definition referencing the rule (or an assignee appearing) cannot slip -in between check and write on this connection. +The schedule-rules/quest-definitions DAO lives in +:mod:`custom_components.nestquest.core.dao_rules` (extracted in task +d0b691d8); this module re-exports it so existing +``from .dao_rules import ...`` references inside the integration keep +working. """ from __future__ import annotations -import datetime -from dataclasses import dataclass - -from .const import QUEST_WINDOWS -from .dao_children import ChildRecord, _child_from_row, _connection_lock -from .dao_children import _CHILD_COLUMNS -from .db import NestQuestDatabase -from .recurrence import RuleType, RuleValidationError, ScheduleRule - -#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL" -#: in update methods: passing ``None`` must mean clearing a nullable -#: column, not skipping it. -_UNSET = object() - -#: Date-shape policy: schedule-rule dates are ISO YYYY-MM-DD. The -#: schema's lexical end>=start CHECK cannot prove a value is a real -#: calendar date, so the DAO validates the shape it accepts. -_DATE_FORMAT = "%Y-%m-%d" - - -def _validate_date(value: str, field: str) -> None: - """Raise ValueError unless ``value`` is a YYYY-MM-DD calendar date. - - ``datetime.date.fromisoformat`` accepts ISO dates but is lenient on - some builds (e.g. '2026-9-4'); strptime with an exact format is - strict, and strptime round-trips the parsed value to reject zero - padding ('2026-9-4' parses but its isoformat differs). - """ - try: - parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date() - except (TypeError, ValueError): - raise ValueError( - f"{field} must be an ISO date (YYYY-MM-DD), got {value!r}" - ) from None - if parsed.isoformat() != value: - raise ValueError( - f"{field} must be a strict YYYY-MM-DD date, got {value!r}" - ) - - -_TIME_FORMAT = "%H:%M" - - -def _validate_time(value: str, field: str) -> None: - """Raise ValueError unless ``value`` is a strict 24-hour HH:MM. - - Same strictness policy as _validate_date: parse with the exact - format, then round-trip so '9:30' (non-padded) is rejected. Time - shapes are caller-side policy (the schema stores TEXT): windows' - optional ``due_time`` and definition ``due_time`` both use this. - """ - try: - parsed = datetime.datetime.strptime(value, _TIME_FORMAT).time() - except (TypeError, ValueError): - raise ValueError( - f"{field} must be a 24-hour HH:MM time, got {value!r}" - ) from None - if parsed.strftime(_TIME_FORMAT) != value: - raise ValueError( - f"{field} must be a strict HH:MM time, got {value!r}" - ) - - -def _validate_window(window: str) -> None: - """Raise ValueError unless ``window`` is a ``const`` window name.""" - if window not in QUEST_WINDOWS: - raise ValueError( - f"window must be one of {', '.join(QUEST_WINDOWS)}, " - f"got {window!r}" - ) - - -@dataclass(frozen=True) -class ScheduleRuleRecord: - """One row of ``schedule_rules``.""" - - id: int - rule_type: str - interval: int - weekday_set: str | None - day_of_month: int | None - nth_weekday: int | None - month: int | None - start_date: str - end_date: str | None - - -@dataclass(frozen=True) -class ScheduleRuleStorage: - """The eight value columns a ScheduleRule maps to (no id column). - - ``id`` is assigned by the database on insert, so the model<->storage - mapping concerns only the value columns. ``nth_weekday_weekday`` is - deliberately absent here: for MONTHLY_WEEKDAY its value is folded - into ``weekday_set`` as that column's single CSV element. - """ - - rule_type: str - interval: int - weekday_set: str | None - day_of_month: int | None - nth_weekday: int | None - month: int | None - start_date: str - end_date: str | None - - -@dataclass(frozen=True) -class QuestDefinitionRecord: - """One row of ``quest_definitions`` (without its assignees).""" - - id: int - title: str - description: str | None - icon: str | None - schedule_rule_id: int - due_time: str | None - is_active: bool - created_at: str - - -@dataclass(frozen=True) -class QuestDefinitionWindowRecord: - """One row of ``quest_definition_windows``.""" - - definition_id: int - window: str - due_time: str | None - - -@dataclass(frozen=True) -class QuestDefinitionSnapshot: - """A definition plus its rule, assignees and windows, read atomically. - - Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows` - and :meth:`QuestDefinitionsDao.edit_definition` with the rule, - assignees and windows read inside the SAME transaction that wrote - them, so the snapshot is a consistent creation/edit-time view — a - concurrent rule, assignee or window mutation cannot change it - between the write and the read. - """ - - definition: QuestDefinitionRecord - rule: ScheduleRuleRecord - assignees: list[ChildRecord] - windows: list[QuestDefinitionWindowRecord] - - -_RULE_COLUMNS = ( - "id, rule_type, interval, weekday_set, day_of_month, nth_weekday, " - "month, start_date, end_date" +from .core.dao_rules import * # noqa: F401,F403 +from .core.dao_rules import ( # noqa: F401 + _UNSET, + _validate_date, + _validate_time, + _validate_window, ) -_DEFINITION_COLUMNS = ( - "id, title, description, icon, schedule_rule_id, due_time, " - "is_active, created_at" -) -_WINDOW_COLUMNS = "definition_id, window, due_time" - - -def _rule_from_row(row: tuple) -> ScheduleRuleRecord: - return ScheduleRuleRecord( - id=row[0], - rule_type=row[1], - interval=row[2], - weekday_set=row[3], - day_of_month=row[4], - nth_weekday=row[5], - month=row[6], - start_date=row[7], - end_date=row[8], - ) - - -def _definition_from_row(row: tuple) -> QuestDefinitionRecord: - return QuestDefinitionRecord( - id=row[0], - title=row[1], - description=row[2], - icon=row[3], - schedule_rule_id=row[4], - due_time=row[5], - is_active=bool(row[6]), - created_at=row[7], - ) - - -def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord: - return QuestDefinitionWindowRecord( - definition_id=row[0], - window=row[1], - due_time=row[2], - ) - - -#: Storage columns that are forbidden for each rule type. A populated -#: forbidden column means the row's shape is invalid and must raise on -#: read, never be silently dropped. ``weekday_set`` is absent for -#: DAILY/MONTHLY_DAY/YEARLY; MONTHLY_WEEKDAY is the only shape allowed a -#: non-NULL ``nth_weekday``; YEARLY is the only shape allowed ``month``. -_FORBIDDEN_STORAGE_FIELDS: dict[str, tuple[RuleType, ...]] = { - "weekday_set": (RuleType.DAILY, RuleType.MONTHLY_DAY, RuleType.YEARLY), - "day_of_month": ( - RuleType.DAILY, - RuleType.WEEKLY, - RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_WEEKDAY, - ), - "nth_weekday": ( - RuleType.DAILY, - RuleType.WEEKLY, - RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_DAY, - RuleType.YEARLY, - ), - "month": ( - RuleType.DAILY, - RuleType.WEEKLY, - RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_DAY, - RuleType.MONTHLY_WEEKDAY, - ), -} - - -def schedule_rule_to_storage(rule: ScheduleRule) -> ScheduleRuleStorage: - """Map a validated ScheduleRule onto its storage columns. - - ``rule_type`` stores as ``RuleType.storage_value`` (MONTHLY_DAY and - MONTHLY_WEEKDAY both store ``'monthly'``); ``weekday_set`` stores as - a sorted CSV list. MONTHLY_WEEKDAY folds its model-only - ``nth_weekday_weekday`` into ``weekday_set`` as that list's single - element; every other type leaves the column as its own set - (WEEKLY/CUSTOM_DAYS) or NULL. - """ - shape = rule.rule_type - if shape is RuleType.MONTHLY_WEEKDAY: - weekday_set = str(rule.nth_weekday_weekday) - elif shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: - weekday_set = ",".join(str(w) for w in sorted(rule.weekday_set)) - else: - weekday_set = None - return ScheduleRuleStorage( - rule_type=shape.storage_value, - interval=rule.interval, - weekday_set=weekday_set, - day_of_month=rule.day_of_month, - nth_weekday=rule.nth_weekday, - month=rule.month, - start_date=rule.start_date, - end_date=rule.end_date, - ) - - -def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule: - """Reconstruct a ScheduleRule from storage columns. - - Disambiguates MONTHLY_DAY from MONTHLY_WEEKDAY by which field is - populated, and raises RuleValidationError rather than silently - mis-decoding an ambiguous or invalid shape. - """ - rule_type = storage.rule_type - if rule_type == "daily": - shape = RuleType.DAILY - elif rule_type == "weekly": - shape = RuleType.WEEKLY - elif rule_type == "monthly": - shape = _disambiguate_monthly(storage) - elif rule_type == "yearly": - shape = RuleType.YEARLY - elif rule_type == "custom": - shape = RuleType.CUSTOM_DAYS - else: - raise RuleValidationError( - f"unknown storage rule_type {rule_type!r}" - ) - - _reject_forbidden_storage_fields(storage, shape) - - weekday_set = None - nth_weekday_weekday = None - if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: - weekday_set = _weekdays_from_csv(storage.weekday_set) - elif shape is RuleType.MONTHLY_WEEKDAY: - nth_weekday_weekday = _single_weekday_from_csv(storage.weekday_set) - - return ScheduleRule( - rule_type=shape, - interval=storage.interval, - weekday_set=weekday_set, - day_of_month=storage.day_of_month, - nth_weekday=storage.nth_weekday, - nth_weekday_weekday=nth_weekday_weekday, - month=storage.month, - start_date=storage.start_date, - end_date=storage.end_date, - ) - - -def schedule_rule_storage_from_record( - record: ScheduleRuleRecord, -) -> ScheduleRuleStorage: - """Copy a rule row's value columns into a storage mapping. - - ``ScheduleRuleRecord`` is the read shape (it carries ``id``); - :func:`schedule_rule_from_storage` consumes the value-only - :class:`ScheduleRuleStorage` shape, so callers re-reading a stored - rule convert through this helper. - """ - return ScheduleRuleStorage( - rule_type=record.rule_type, - interval=record.interval, - weekday_set=record.weekday_set, - day_of_month=record.day_of_month, - nth_weekday=record.nth_weekday, - month=record.month, - start_date=record.start_date, - end_date=record.end_date, - ) - - -def _reject_forbidden_storage_fields( - storage: ScheduleRuleStorage, shape: RuleType -) -> None: - """Raise unless every populated column is permitted for ``shape``. - - The model constructor rejects a forbidden ``day_of_month``, - ``nth_weekday``, or ``month``, but ``weekday_set`` never reaches it - (it is folded for MONTHLY_WEEKDAY and dropped otherwise), so a daily - row carrying ``weekday_set="0"`` would otherwise decode as a valid - daily rule and silently lose the stored data. This validates every - shape column explicitly so a populated-forbidden column raises - instead of being discarded. - """ - for field, forbidden_shapes in _FORBIDDEN_STORAGE_FIELDS.items(): - value = getattr(storage, field) - if value is not None and shape in forbidden_shapes: - raise RuleValidationError( - f"{shape.value} storage rows must not set {field}, " - f"got {value!r}" - ) - - -def _disambiguate_monthly(storage: ScheduleRuleStorage) -> RuleType: - """Resolve the shared 'monthly' storage value to a model type. - - MONTHLY_DAY names ``day_of_month`` only; MONTHLY_WEEKDAY names - ``nth_weekday`` only. Neither, or both, is ambiguous and raises. - """ - day_of_month = storage.day_of_month - nth_weekday = storage.nth_weekday - if day_of_month is not None and nth_weekday is None: - return RuleType.MONTHLY_DAY - if nth_weekday is not None and day_of_month is None: - return RuleType.MONTHLY_WEEKDAY - raise RuleValidationError( - "storage rule_type 'monthly' is ambiguous: must name exactly one " - "of day_of_month or nth_weekday, got " - f"day_of_month={day_of_month!r}, nth_weekday={nth_weekday!r}" - ) - - -def _weekdays_from_csv(value: str | None) -> frozenset[int]: - """Parse a weekday CSV column into a frozenset of ints. - - ``None`` and ``""`` both yield an empty frozenset (the caller decides - whether empty is legal); a non-integer token raises - RuleValidationError rather than leaking a raw ValueError. - """ - if value is None or value == "": - return frozenset() - try: - entries = [int(token) for token in value.split(",")] - except ValueError: - raise RuleValidationError( - f"weekday_set must be a CSV of integers, got {value!r}" - ) from None - return frozenset(entries) - - -def _single_weekday_from_csv(value: str | None) -> int: - """The one weekday a MONTHLY_WEEKDAY stores in weekday_set. - - Parses the CSV into tokens FIRST and requires exactly one token, - so a duplicate pair like ``"1,1"`` is rejected rather than silently - canonicalized to a single weekday on re-storage. An empty or - multi-element column raises RuleValidationError. - """ - if value is None or value == "": - raise RuleValidationError( - "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " - f"weekday, got {value!r}" - ) - tokens = value.split(",") - if len(tokens) != 1: - raise RuleValidationError( - "MONTHLY_WEEKDAY storage weekday_set must hold exactly one " - f"weekday, got {value!r}" - ) - try: - return int(tokens[0]) - except ValueError: - raise RuleValidationError( - "MONTHLY_WEEKDAY storage weekday_set must be an integer, " - f"got {value!r}" - ) from None - - -async def _insert_rule_row( - database: NestQuestDatabase, storage: ScheduleRuleStorage -) -> int: - """Insert one ``schedule_rules`` row and return its new id. - - Runs inside the caller's transaction (no lock or transaction of its - own) so a multi-table write can persist the rule together with the - rows that reference it. The caller is responsible for validating - the storage fields — :meth:`ScheduleRulesDao.create` validates the - date shape, and :meth:`QuestDefinitionsDao.create_with_rule_and_windows` - receives storage already mapped from a validated - :class:`~.recurrence.ScheduleRule`. - """ - result = await database.execute( - "INSERT INTO schedule_rules (rule_type, interval, weekday_set, " - "day_of_month, nth_weekday, month, start_date, end_date) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ( - storage.rule_type, - storage.interval, - storage.weekday_set, - storage.day_of_month, - storage.nth_weekday, - storage.month, - storage.start_date, - storage.end_date, - ), - ) - return result.lastrowid - - -class ScheduleRulesDao: - """Typed async access to the ``schedule_rules`` table.""" - - def __init__(self, database: NestQuestDatabase) -> None: - self._database = database - - async def create( - self, - rule_type: str, - start_date: str, - *, - interval: int = 1, - weekday_set: str | None = None, - day_of_month: int | None = None, - nth_weekday: int | None = None, - month: int | None = None, - end_date: str | None = None, - ) -> ScheduleRuleRecord: - """Insert one rule and return the record as stored. - - Rule-type coherence (a weekly rule needs ``weekday_set``, a - yearly rule needs ``month``, etc.) is enforced by the schema - CHECKs and surfaces as :class:`sqlite3.IntegrityError`. Date - shape (strict YYYY-MM-DD) is validated here because the - schema's lexical end>=start CHECK cannot prove a real calendar - date. - """ - _validate_date(start_date, "start_date") - if end_date is not None: - _validate_date(end_date, "end_date") - rule_id = await _insert_rule_row( - self._database, - ScheduleRuleStorage( - rule_type=rule_type, - interval=interval, - weekday_set=weekday_set, - day_of_month=day_of_month, - nth_weekday=nth_weekday, - month=month, - start_date=start_date, - end_date=end_date, - ), - ) - rule = await self.get(rule_id) - assert rule is not None - return rule - - async def get(self, rule_id: int) -> ScheduleRuleRecord | None: - """Return the rule with ``rule_id``, or None.""" - row = await self._database.fetch_one( - f"SELECT {_RULE_COLUMNS} FROM schedule_rules WHERE id = ?", - (rule_id,), - ) - return _rule_from_row(row) if row is not None else None - - async def update( - self, - rule_id: int, - *, - rule_type: str | None | object = _UNSET, - interval: int | None | object = _UNSET, - weekday_set: str | None | object = _UNSET, - day_of_month: int | None | object = _UNSET, - nth_weekday: int | None | object = _UNSET, - month: int | None | object = _UNSET, - start_date: str | None | object = _UNSET, - end_date: str | None | object = _UNSET, - ) -> int: - """Update the given fields; returns rows updated (0 if absent). - - Arguments default to the module sentinel ``_UNSET`` meaning - "leave this column alone"; passing ``None`` explicitly writes - SQL NULL, so callers can reopen an ended rule (``end_date`` - back to NULL) or clear nullable shape fields. Passing - ``None``/a new value for a date field validates its shape. - Editing a rule that a definition references changes future - materializations only — this method touches no instances - (Feature 06/07 separation). - """ - date_args = ( - ("start_date", start_date), - ("end_date", end_date), - ) - for field, value in date_args: - if value is not _UNSET and value is not None: - _validate_date(value, field) - assignments: list[str] = [] - parameters: list[object] = [] - for column, value in ( - ("rule_type", rule_type), - ("interval", interval), - ("weekday_set", weekday_set), - ("day_of_month", day_of_month), - ("nth_weekday", nth_weekday), - ("month", month), - ("start_date", start_date), - ("end_date", end_date), - ): - if value is not _UNSET: - assignments.append(f"{column} = ?") - parameters.append(value) - if not assignments: - return 0 - parameters.append(rule_id) - result = await self._database.execute( - f"UPDATE schedule_rules SET {', '.join(assignments)} " - "WHERE id = ?", - tuple(parameters), - ) - return result.rowcount - - async def delete_if_unreferenced(self, rule_id: int) -> bool: - """Delete the rule only if no definition references it. - - Returns True if the rule was deleted, False if it is still - referenced (or does not exist — distinguish via :meth:`get`). - The reference check and the DELETE run inside one transaction - under the connection lock, so a definition cannot start - referencing the rule between the check and the delete. - """ - async with _connection_lock(self._database): - async with self._database.transaction(): - row = await self._database.fetch_one( - "SELECT 1 FROM quest_definitions " - "WHERE schedule_rule_id = ? LIMIT 1", - (rule_id,), - ) - if row is not None: - return False - result = await self._database.execute( - "DELETE FROM schedule_rules WHERE id = ?", - (rule_id,), - ) - return result.rowcount > 0 - - -class QuestDefinitionsDao: - """Typed async access to ``quest_definitions`` and its assignees. - - No delete method exists: definitions are deactivated via - :meth:`set_active`, never hard-deleted, so completion history keeps - its references (feature guardrail). Assignment is multi-assignee - (D-008): the definition row carries no child; assignees live in - ``quest_definition_assignees`` and are managed explicitly so the - operations stay separately loggable and permission-gateable. - """ - - def __init__(self, database: NestQuestDatabase) -> None: - self._database = database - - async def create( - self, - title: str, - schedule_rule_id: int, - created_at: str, - *, - description: str | None = None, - icon: str | None = None, - due_time: str | None = None, - is_active: bool = True, - assignee_child_ids: list[int] | None = None, - ) -> QuestDefinitionRecord: - """Insert one definition and return the record as stored. - - Validates the schedule rule as part of the same serialized - transaction as the insert: the rule must exist AT INSERT TIME — - a rule deleted concurrently cannot slip between the check and - the INSERT. ``assignee_child_ids`` (each child must exist and - be active) is applied in the same transaction, so a definition - can never be observed with a stale or missing assignee set; an - empty list or None creates an unassigned definition, which - callers may fill via :meth:`add_assignee`. - """ - async with _connection_lock(self._database): - async with self._database.transaction(): - await self._validate_rule_exists(schedule_rule_id) - for child_id in assignee_child_ids or []: - await self._validate_assignable_child(child_id) - result = await self._database.execute( - "INSERT INTO quest_definitions (title, description, " - "icon, schedule_rule_id, due_time, is_active, " - "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - ( - title, - description, - icon, - schedule_rule_id, - due_time, - int(is_active), - created_at, - ), - ) - definition_id = result.lastrowid - for child_id in assignee_child_ids or []: - await self._database.execute( - "INSERT INTO quest_definition_assignees " - "(definition_id, child_id) VALUES (?, ?)", - (definition_id, child_id), - ) - definition = await self.get(definition_id) - assert definition is not None - return definition - - async def create_with_rule_and_windows( - self, - title: str, - schedule_rule: ScheduleRuleStorage, - created_at: str, - assignee_child_ids: list[int], - windows: list[tuple[str, str | None]], - *, - description: str | None = None, - icon: str | None = None, - ) -> QuestDefinitionSnapshot: - """Insert a rule, definition, assignees and windows atomically. - - The whole write — the schedule rule row, the definition row, - every assignee link and every window declaration — runs inside - ONE transaction under the connection lock, so a validation or - write failure rolls the lot back together (all-or-nothing): the - caller can never observe a definition with a missing rule, - assignee or window, and a rejected create leaves no partial - rows. - - Returns a :class:`QuestDefinitionSnapshot` whose assignees and - windows are read back inside the same transaction, so the - returned view cannot be raced by a concurrent assignee/window - mutation after the write. - - The rule arrives as pre-validated storage fields (mapped from a - :class:`~.recurrence.ScheduleRule` via - :func:`schedule_rule_to_storage`); its dates are re-validated - here so a hand-built storage can never persist a malformed date. - The definition-level ``due_time`` column is deliberately left - NULL — per-window due times supersede it (D-008). Assignees are - validated as existing-and-active at insert time, and windows are - validated for name and strict HH:MM due time, matching - :meth:`upsert_window`. - """ - _validate_date(schedule_rule.start_date, "start_date") - if schedule_rule.end_date is not None: - _validate_date(schedule_rule.end_date, "end_date") - async with _connection_lock(self._database): - async with self._database.transaction(): - for child_id in assignee_child_ids: - await self._validate_assignable_child(child_id) - rule_id = await _insert_rule_row(self._database, schedule_rule) - result = await self._database.execute( - "INSERT INTO quest_definitions (title, description, " - "icon, schedule_rule_id, due_time, is_active, " - "created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - (title, description, icon, rule_id, None, 1, created_at), - ) - definition_id = result.lastrowid - for child_id in assignee_child_ids: - await self._database.execute( - "INSERT INTO quest_definition_assignees " - "(definition_id, child_id) VALUES (?, ?)", - (definition_id, child_id), - ) - for window, due_time in windows: - _validate_window(window) - if due_time is not None: - _validate_time(due_time, "due_time") - await self._database.execute( - "INSERT INTO quest_definition_windows " - "(definition_id, window, due_time) VALUES (?, ?, ?)", - (definition_id, window, due_time), - ) - definition = await self.get(definition_id) - assignees = await self.list_assignees(definition_id) - window_records = await self.list_windows(definition_id) - rule_record = await ScheduleRulesDao(self._database).get( - rule_id - ) - assert definition is not None - assert rule_record is not None - return QuestDefinitionSnapshot( - definition=definition, - rule=rule_record, - assignees=assignees, - windows=window_records, - ) - - async def edit_definition( - self, - definition_id: int, - *, - title: str | None | object = _UNSET, - description: str | None | object = _UNSET, - icon: str | None | object = _UNSET, - rule: ScheduleRuleStorage | object = _UNSET, - windows: list[tuple[str, str | None]] | object = _UNSET, - ) -> QuestDefinitionSnapshot: - """Edit a definition's metadata, rule and windows atomically. - - Mirrors :meth:`create_with_rule_and_windows`: everything runs - inside ONE transaction under the connection lock, and the - returned snapshot's rule, assignees and windows are read back - inside that transaction. Arguments default to the module sentinel - ``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL - NULL so optional metadata (description, icon) can be cleared. - - - ``title``/``description``/``icon`` update the definition row - in place; the definition-level ``due_time`` column is never - written (per-window due times supersede it, D-008). - - ``rule`` (a pre-validated :class:`ScheduleRuleStorage`) - overwrites the referenced ``schedule_rules`` row in place: the - definition keeps its ``schedule_rule_id``, so no new or - orphaned rule row appears. - - ``windows`` REPLACES the whole window set: the current rows - are deleted and the given set re-inserted, so missing windows - are added, changed due times updated and removed windows - dropped. - - Assignment is deliberately not settable here (multi-assignee is - managed by :meth:`add_assignee`/:meth:`remove_assignee`), and - activation is untouched. Raises ValueError when the definition - does not exist. Edits change future instances only: no - ``quest_instances`` or ``completion_events`` row is touched. - """ - async with _connection_lock(self._database): - async with self._database.transaction(): - definition = await self.get(definition_id) - if definition is None: - raise ValueError( - f"quest definition {definition_id} does not exist" - ) - assignments: list[str] = [] - parameters: list[object] = [] - for column, value in ( - ("title", title), - ("description", description), - ("icon", icon), - ): - if value is not _UNSET: - assignments.append(f"{column} = ?") - parameters.append(value) - if assignments: - parameters.append(definition_id) - await self._database.execute( - f"UPDATE quest_definitions SET " - f"{', '.join(assignments)} WHERE id = ?", - tuple(parameters), - ) - if rule is not _UNSET: - await ScheduleRulesDao(self._database).update( - definition.schedule_rule_id, - rule_type=rule.rule_type, - interval=rule.interval, - weekday_set=rule.weekday_set, - day_of_month=rule.day_of_month, - nth_weekday=rule.nth_weekday, - month=rule.month, - start_date=rule.start_date, - end_date=rule.end_date, - ) - if windows is not _UNSET: - await self._database.execute( - "DELETE FROM quest_definition_windows " - "WHERE definition_id = ?", - (definition_id,), - ) - for window, due_time in windows: - _validate_window(window) - if due_time is not None: - _validate_time(due_time, "due_time") - await self._database.execute( - "INSERT INTO quest_definition_windows " - "(definition_id, window, due_time) " - "VALUES (?, ?, ?)", - (definition_id, window, due_time), - ) - updated = await self.get(definition_id) - assignees = await self.list_assignees(definition_id) - window_records = await self.list_windows(definition_id) - rule_record = await ScheduleRulesDao(self._database).get( - updated.schedule_rule_id - ) - assert updated is not None - assert rule_record is not None - return QuestDefinitionSnapshot( - definition=updated, - rule=rule_record, - assignees=assignees, - windows=window_records, - ) - - async def _validate_rule_exists(self, schedule_rule_id: int) -> None: - """Raise ValueError unless the rule exists. - - MUST be called inside the connection lock so the verdict cannot - go stale before the caller's INSERT commits. - """ - rule = await self._database.fetch_one( - "SELECT 1 FROM schedule_rules WHERE id = ?", - (schedule_rule_id,), - ) - if rule is None: - raise ValueError( - f"schedule rule {schedule_rule_id} does not exist" - ) - - async def get(self, definition_id: int) -> QuestDefinitionRecord | None: - """Return the definition with ``definition_id``, or None.""" - row = await self._database.fetch_one( - f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " - "WHERE id = ?", - (definition_id,), - ) - return _definition_from_row(row) if row is not None else None - - async def list_by_child(self, child_id: int) -> list[QuestDefinitionRecord]: - """Return all definitions assigned to ``child_id``, newest first. - - Assignment is read through ``quest_definition_assignees`` - (D-008): a definition appears once per child regardless of how - many assignees share it. - """ - rows = await self._database.fetch_all( - f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " - "WHERE id IN (SELECT definition_id " - "FROM quest_definition_assignees WHERE child_id = ?) " - "ORDER BY id DESC", - (child_id,), - ) - return [_definition_from_row(row) for row in rows] - - async def list_active(self) -> list[QuestDefinitionRecord]: - """Return all active definitions, oldest first (stable order).""" - rows = await self._database.fetch_all( - f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions " - "WHERE is_active = 1 ORDER BY id" - ) - return [_definition_from_row(row) for row in rows] - - async def _snapshots_for( - self, definitions: list[QuestDefinitionRecord] - ) -> list[QuestDefinitionSnapshot]: - """Read each definition's rule, assignees and windows. - - MUST be called inside the connection lock (and ideally a - transaction) so the definitions handed in and the rows this - reads back cannot be raced by a concurrent edit between list - and bundle. Each rule is read through - :class:`ScheduleRulesDao` on the same connection, so the - snapshot's rule record is the one the definition references AT - the moment of the locked read. - """ - rules_dao = ScheduleRulesDao(self._database) - snapshots: list[QuestDefinitionSnapshot] = [] - for definition in definitions: - rule_record = await rules_dao.get(definition.schedule_rule_id) - assert rule_record is not None - snapshots.append( - QuestDefinitionSnapshot( - definition=definition, - rule=rule_record, - assignees=await self.list_assignees(definition.id), - windows=await self.list_windows(definition.id), - ) - ) - return snapshots - - async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]: - """Return every active definition with its rule, assignees and - windows, all read inside one locked transaction. - - The definitive list and each definition's linked rows are read - in a single BEGIN..COMMIT span under the connection lock, so a - concurrent atomic edit cannot interleave between the definition - list and the bundle read: the caller receives a coherent - snapshot (each definition's rule, assignees and windows as they - were at one instant), never a mixed old-rule/new-windows state. - """ - async with _connection_lock(self._database): - async with self._database.transaction(): - definitions = await self.list_active() - return await self._snapshots_for(definitions) - - async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]: - """Return every active definition's snapshot WITHOUT locking. - - Mirrors :meth:`list_snapshots_active` minus the lock and - transaction: the definitions and their rules, assignees and - windows are read on the caller's connection under whatever - transaction the caller is already holding, so they stay coherent - with every OTHER table the caller reads inside that same - transaction. MUST be called inside the connection lock and an - open transaction (the single-transaction input snapshot the - materialization walk uses is that caller). - """ - definitions = await self.list_active() - return await self._snapshots_for(definitions) - - async def list_snapshots_by_child( - self, child_id: int - ) -> list[QuestDefinitionSnapshot]: - """Return each definition assigned to ``child_id`` with its rule, - assignees and windows, all read inside one locked transaction. - - Mirrors :meth:`list_snapshots_active` but selects by assignment - (``list_by_child`` order: newest first). Locks read-and-bundle - into one transaction so the bundle never mixes one edit's - definition with another's windows. - """ - async with _connection_lock(self._database): - async with self._database.transaction(): - definitions = await self.list_by_child(child_id) - return await self._snapshots_for(definitions) - - async def update( - self, - definition_id: int, - *, - title: str | None | object = _UNSET, - description: str | None | object = _UNSET, - icon: str | None | object = _UNSET, - due_time: str | None | object = _UNSET, - ) -> int: - """Update the given fields; returns rows updated (0 if absent). - - Arguments default to the module sentinel ``_UNSET`` meaning - "leave this column alone"; passing ``None`` explicitly writes - SQL NULL, so callers can remove optional metadata - (description, icon, due_time). Assignment and activation are - deliberately NOT settable here: they have their own explicit - operations (:meth:`add_assignee`, :meth:`remove_assignee`, - :meth:`set_active`) so they stay separately loggable and - permission-gateable. - """ - assignments: list[str] = [] - parameters: list[object] = [] - for column, value in ( - ("title", title), - ("description", description), - ("icon", icon), - ("due_time", due_time), - ): - if value is not _UNSET: - assignments.append(f"{column} = ?") - parameters.append(value) - if not assignments: - return 0 - parameters.append(definition_id) - result = await self._database.execute( - f"UPDATE quest_definitions SET {', '.join(assignments)} " - "WHERE id = ?", - tuple(parameters), - ) - return result.rowcount - - async def set_active(self, definition_id: int, is_active: bool) -> int: - """Deactivate/reactivate; returns rows updated (0 if absent). - - Deactivating stops future instance generation but leaves - existing instances and completion history untouched. - """ - result = await self._database.execute( - "UPDATE quest_definitions SET is_active = ? WHERE id = ?", - (int(is_active), definition_id), - ) - return result.rowcount - - async def add_assignee( - self, definition_id: int, child_id: int - ) -> list[ChildRecord]: - """Assign ``child_id`` to the definition (idempotent). - - Validates the assignee inside the same serialized transaction - as the INSERT (existing and active AT ASSIGNMENT TIME), so a - child deactivated concurrently cannot become an assignee. - Assigning an already-assigned child is a no-op, mirroring the - composite primary key's storage-level guarantee. Returns the - definition's assignees read back INSIDE the same transaction - that performed the write, so the returned roster is a - consistent post-assignment snapshot — a concurrent assign or - unassign cannot race it after the write commits. Changes - future instances only: already-generated instances and - completion history are never rewritten here. - """ - async with _connection_lock(self._database): - async with self._database.transaction(): - await self._validate_assignable_child(child_id) - 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" - ) - await self._database.execute( - "INSERT INTO quest_definition_assignees " - "(definition_id, child_id) VALUES (?, ?) " - "ON CONFLICT (definition_id, child_id) DO NOTHING", - (definition_id, child_id), - ) - return await self.list_assignees(definition_id) - - async def remove_assignee( - self, definition_id: int, child_id: int - ) -> bool: - """Remove the assignee link; True when a row was removed. - - Future materialization stops for this (definition, child) pair; - existing instances and completion history are never touched - here (Feature 06 guardrail). - """ - async with _connection_lock(self._database): - result = await self._database.execute( - "DELETE FROM quest_definition_assignees " - "WHERE definition_id = ? AND child_id = ?", - (definition_id, child_id), - ) - return result.rowcount > 0 - - async def list_assignees(self, definition_id: int) -> list[ChildRecord]: - """Return the definition's assignees as child records. - - Ordered by the children table's stable display order - (``sort_order``, then id) so callers render a deterministic - roster. A definition with no assignees returns []. - """ - rows = await self._database.fetch_all( - f"SELECT {_CHILD_COLUMNS} FROM children " - "WHERE id IN (SELECT child_id " - "FROM quest_definition_assignees WHERE definition_id = ?) " - "ORDER BY sort_order, id", - (definition_id,), - ) - return [_child_from_row(row) for row in rows] - - async def upsert_window( - self, - definition_id: int, - window: str, - *, - due_time: str | None = None, - ) -> QuestDefinitionWindowRecord: - """Declare (or re-declare) ``window`` on the definition. - - The composite primary key makes the write idempotent per - (definition, window): re-declaring updates ``due_time`` in - place instead of duplicating. The window name must be one of - ``const.QUEST_WINDOWS`` (the schema CHECK backs this up) and a - provided ``due_time`` must be strict 24-hour HH:MM. The - definition must exist — validated under the connection lock so - the verdict cannot go stale before the write. - """ - _validate_window(window) - if due_time is not None: - _validate_time(due_time, "due_time") - async with _connection_lock(self._database): - async with self._database.transaction(): - 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" - ) - await self._database.execute( - "INSERT INTO quest_definition_windows " - "(definition_id, window, due_time) VALUES (?, ?, ?) " - "ON CONFLICT (definition_id, window) DO UPDATE SET " - "due_time = excluded.due_time", - (definition_id, window, due_time), - ) - row = await self._database.fetch_one( - f"SELECT {_WINDOW_COLUMNS} " - "FROM quest_definition_windows " - "WHERE definition_id = ? AND window = ?", - (definition_id, window), - ) - assert row is not None - return _window_from_row(row) - - async def list_windows( - self, definition_id: int - ) -> list[QuestDefinitionWindowRecord]: - """Return the definition's windows in ``const`` order. - - Ordered by the canonical window sequence (morning, afternoon, - evening) rather than insertion order, so callers always render - the Quest Log's three columns consistently. A definition with - no windows returns []. - """ - rows = await self._database.fetch_all( - f"SELECT {_WINDOW_COLUMNS} FROM quest_definition_windows " - "WHERE definition_id = ?", - (definition_id,), - ) - by_name = {row[1]: _window_from_row(row) for row in rows} - return [ - by_name[name] - for name in QUEST_WINDOWS - if name in by_name - ] - - async def remove_window( - self, definition_id: int, window: str - ) -> bool: - """Remove the window declaration; True when a row was removed. - - Future materialization stops producing instances for this - (definition, window); existing instances and completion history - are never touched here (Feature 06 guardrail). - """ - _validate_window(window) - async with _connection_lock(self._database): - result = await self._database.execute( - "DELETE FROM quest_definition_windows " - "WHERE definition_id = ? AND window = ?", - (definition_id, window), - ) - return result.rowcount > 0 - - async def _validate_assignable_child(self, child_id: int) -> None: - """Raise ValueError unless the child exists and is active. - - MUST be called inside the connection lock so the verdict cannot - go stale before the caller's INSERT commits. - """ - child = await self._database.fetch_one( - "SELECT is_active FROM children WHERE id = ?", (child_id,) - ) - if child is None: - raise ValueError(f"child {child_id} does not exist") - if not child[0]: - raise ValueError( - f"child {child_id} is inactive and cannot be assigned" - ) - - -async def load_materialization_input( - database: NestQuestDatabase, - range_start: str, - range_end: str, -): - """Read every materialization input in ONE locked transaction. - - Returns ``(snapshots, schedules, overrides)``: - - - ``snapshots``: every active definition with its rule, assignees and - windows (the :class:`QuestDefinitionSnapshot` shape). - - ``schedules`` / ``overrides``: the presence schedules and - range-scoped overrides of every assignee child, in - :mod:`.dao_presence`'s record shapes. - - The whole read — definitions, rules, assignees, windows, and the - assignees' presence schedules and overrides — runs inside a single - BEGIN..COMMIT span under the connection lock, so a concurrent edit - (definition, assignment, window, or presence) cannot interleave - between the parts and the walk never sees a mixed-time view. Each - table's SQL stays in its own DAO module: the presence portion is - delegated to :mod:`.dao_presence` (a lazy import here, since that - module imports this one). - """ - definitions_dao = QuestDefinitionsDao(database) - async with _connection_lock(database): - async with database.transaction(): - snapshots = await definitions_dao.read_snapshots_active() - child_ids = sorted( - { - child.id - for snapshot in snapshots - for child in snapshot.assignees - } - ) - from . import dao_presence # lazy: dao_presence imports us - - schedules, overrides = await dao_presence.read_snapshot_unlocked( - database, child_ids, range_start, range_end - ) - return snapshots, schedules, overrides \ No newline at end of file diff --git a/custom_components/nestquest/db.py b/custom_components/nestquest/db.py index c05af6b..b13a2e0 100644 --- a/custom_components/nestquest/db.py +++ b/custom_components/nestquest/db.py @@ -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 \ No newline at end of file + return NestQuestDatabase(hass.async_add_executor_job) diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py index 129af0b..8384333 100644 --- a/custom_components/nestquest/events.py +++ b/custom_components/nestquest/events.py @@ -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) diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index c32d3d9..a6cb1b1 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -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 diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index cf40a7a..d51d3c7 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -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 \ No newline at end of file +from .core.migrations import * # noqa: F401,F403 diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index bf5c59f..22e3ee2 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -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]: diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 465c105..56538b3 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -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 diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py index d301aa4..099fc46 100644 --- a/custom_components/nestquest/quest_definitions.py +++ b/custom_components/nestquest/quest_definitions.py @@ -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 diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index c114568..7e72994 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -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 diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index b3c3223..6ed278a 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -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) \ No newline at end of file +from .core.schema import * # noqa: F401,F403 diff --git a/custom_components/nestquest/service_policy.py b/custom_components/nestquest/service_policy.py index 823bed4..f36d94a 100644 --- a/custom_components/nestquest/service_policy.py +++ b/custom_components/nestquest/service_policy.py @@ -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 diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index 0c66635..2ce49dd 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -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]: diff --git a/custom_components/nestquest/store.py b/custom_components/nestquest/store.py index 8198d79..e87a7d7 100644 --- a/custom_components/nestquest/store.py +++ b/custom_components/nestquest/store.py @@ -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 \ No newline at end of file + """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)) diff --git a/tests/conftest.py b/tests/conftest.py index 6d6c9c4..1e4b728 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py index 5640d2d..1ca1224 100644 --- a/tests/test_admin_allowlist.py +++ b/tests/test_admin_allowlist.py @@ -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) diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index 6b84a18..6ad47b5 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -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) diff --git a/tests/test_completion.py b/tests/test_completion.py index 808096f..2b3cd02 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -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) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 246e59b..a6ecc2d 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -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" diff --git a/tests/test_const.py b/tests/test_const.py index 361ca83..4d8592a 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -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" diff --git a/tests/test_core_isolation.py b/tests/test_core_isolation.py new file mode 100644 index 0000000..125bed9 --- /dev/null +++ b/tests/test_core_isolation.py @@ -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.``, +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 import ``.""" + 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) + ) diff --git a/tests/test_core_no_ha_smoke.py b/tests/test_core_no_ha_smoke.py new file mode 100644 index 0000000..69a3dce --- /dev/null +++ b/tests/test_core_no_ha_smoke.py @@ -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 +