diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4823457..5bd20da 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -13,6 +13,15 @@ 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 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/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/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/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/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/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/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/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/tests/test_children_registry.py b/tests/test_children_registry.py index a3b554c..6ad47b5 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -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. @@ -365,7 +365,7 @@ def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None: try: await apply_migrations(database) logger = logging.getLogger( - "custom_components.nestquest.children" + "custom_components.nestquest.core.children" ) seen: list[logging.LogRecord] = [] diff --git a/tests/test_core_isolation.py b/tests/test_core_isolation.py index 3ff816d..125bed9 100644 --- a/tests/test_core_isolation.py +++ b/tests/test_core_isolation.py @@ -4,9 +4,10 @@ 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 -storage-layer extraction (task d0b691d8) from regressing into a -HA-coupled or integration-coupled bundle. +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 diff --git a/tests/test_events.py b/tests/test_events.py index 829f4ea..8d520f6 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -31,7 +31,19 @@ ADMIN_CTX = {"user_id": "admin-1"} _TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") -async def _setup_and_seed(hass, make_entry, *, windows=("morning",)): +async def _setup_and_seed(hass, make_entry, *, windows=("morning",), seed_date=None): + """Seed a household and materialize today's instances. + + ``seed_date`` pins the date instances are materialized for (and the + daily rule's ``start_date``); it defaults to the host's + ``datetime.date.today()``. Callers that need to test the day-complete + rule against a non-UTC HA-local timezone pass the local zone's + ``today`` here so the seeded instance's ``due_date`` matches it. + """ + import datetime + + if seed_date is None: + seed_date = datetime.date.today() entry = wire_entry_to_registry( make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry ) @@ -43,7 +55,6 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",)): {"action": "create", "display_name": "Ada"}, context=ADMIN_CTX, ) - child = None from custom_components.nestquest.children import list_children child = (await list_children(database))[0] @@ -51,18 +62,16 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",)): database, "Brush teeth", ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": __import__("datetime").date.today().isoformat()} + {"rule_type": "daily", "start_date": seed_date.isoformat()} ), [child.id], list(windows), ) - import datetime - - today = datetime.date.today().isoformat() - end = (datetime.date.today() + datetime.timedelta(days=3)).isoformat() - await materialize(database, today, end, today=datetime.date.today()) + today_iso = seed_date.isoformat() + end_iso = (seed_date + datetime.timedelta(days=3)).isoformat() + await materialize(database, today_iso, end_iso, today=seed_date) instances = await QuestInstancesDao(database).list_by_date_range( - child.id, today, today + child.id, today_iso, today_iso ) return entry, child, instances @@ -377,3 +386,408 @@ async def test_forced_refreshes_serialize_no_stale_publish( assert order == ["start", "end", "start", "end"], ( "overlapping refreshes must serialize: " + str(order) ) + + +# --------------------------------------------------------------------------- +# Direct core-builder unit tests (P2.2a): exact payload key sets, event +# order, and was_on_time threading. These target +# custom_components.nestquest.core.events directly, bypassing the shim, +# so a payload-shape regression is caught even if the shim masks it. +# --------------------------------------------------------------------------- + +_COMPLETED_KEYS = { + "child_id", + "child_name", + "instance_id", + "quest_title", + "window", + "due_date", + "due_time", + "occurred_at", + "was_on_time", +} +_UNCOMPLETED_KEYS = { + "child_id", + "child_name", + "instance_id", + "quest_title", + "window", + "due_date", + "due_time", + "occurred_at", +} +_DAY_COMPLETE_KEYS = { + "child_id", + "child_name", + "quests_due", + "quests_completed", + "occurred_at", +} + + +async def test_build_quest_completed_event_payload_key_set(hass, make_entry) -> None: + """The completed-event payload carries exactly the documented keys + (no extra fields, no missing field) and threads ``was_on_time``.""" + from custom_components.nestquest.core.events import build_quest_completed_event + + entry, child, instances = await _setup_and_seed(hass, make_entry) + database = entry.runtime_data.database + event_type, payload = await build_quest_completed_event( + database, instances[0].id, was_on_time=True + ) + assert event_type == EVENT_QUEST_COMPLETED + assert set(payload) == _COMPLETED_KEYS, ( + f"completed payload keys drift: got {set(payload)} expected {_COMPLETED_KEYS}" + ) + assert payload["was_on_time"] is True + assert payload["child_id"] == child.id + assert payload["child_name"] == "Ada" + assert payload["instance_id"] == instances[0].id + assert payload["quest_title"] == "Brush teeth" + assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"] + + +async def test_build_quest_completed_event_was_on_time_threads_all_shapes( + hass, make_entry +) -> None: + """``was_on_time`` is carried verbatim — True, False, and None + (the unknown-timing shape) all round-trip into the payload.""" + from custom_components.nestquest.core.events import build_quest_completed_event + + entry, _child, instances = await _setup_and_seed(hass, make_entry) + database = entry.runtime_data.database + for wanted in (True, False, None): + _etype, payload = await build_quest_completed_event( + database, instances[0].id, was_on_time=wanted + ) + assert payload["was_on_time"] is wanted, ( + f"was_on_time={wanted!r} did not thread through" + ) + + +async def test_build_quest_uncompleted_events_payload_key_set( + hass, make_entry +) -> None: + """The uncompleted-event payload carries exactly the documented keys + (no ``was_on_time`` — that field is completion-only).""" + from custom_components.nestquest.core.events import ( + build_quest_uncompleted_events, + ) + + entry, _child, instances = await _setup_and_seed(hass, make_entry) + database = entry.runtime_data.database + events = await build_quest_uncompleted_events(database, instances[0].id) + assert len(events) == 1 + event_type, payload = events[0] + assert event_type == EVENT_QUEST_UNCOMPLETED + assert set(payload) == _UNCOMPLETED_KEYS, ( + f"uncompleted payload keys drift: got {set(payload)} " + f"expected {_UNCOMPLETED_KEYS}" + ) + assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"] + + +async def test_build_child_day_complete_event_payload_key_set( + hass, make_entry +) -> None: + """The day-complete payload carries exactly the documented keys + when the child's whole day clears, and reuses the completed + event's ``occurred_at`` stamp (one transition, one timestamp).""" + import datetime + + from custom_components.nestquest.core.events import ( + build_child_day_complete_event, + build_quest_completed_event, + ) + + entry, _child, instances = await _setup_and_seed(hass, make_entry) + database = entry.runtime_data.database + today = datetime.date.today() + completed_type, completed_payload = await build_quest_completed_event( + database, instances[0].id, was_on_time=True + ) + # Mark every today-instance done so the day clears. + from custom_components.nestquest.completion import complete_instance + + for instance in instances: + await complete_instance( + database, + instance.id, + actor_source="panel", + actor_child_id=_child.id, + today=today, + ) + day_event = await build_child_day_complete_event( + database, completed_payload, today=today + ) + assert day_event is not None + event_type, day_payload = day_event + assert event_type == EVENT_CHILD_DAY_COMPLETE + assert set(day_payload) == _DAY_COMPLETE_KEYS, ( + f"day-complete payload keys drift: got {set(day_payload)} " + f"expected {_DAY_COMPLETE_KEYS}" + ) + assert day_payload["occurred_at"] == completed_payload["occurred_at"], ( + "day-complete must reuse the completed event's occurred_at stamp" + ) + assert day_payload["quests_due"] == len(instances) + assert day_payload["quests_completed"] == len(instances) + + +async def test_shim_fires_completed_before_day_complete(hass, make_entry) -> None: + """The shim fires ``nestquest_quest_completed`` BEFORE + ``nestquest_child_day_complete`` (the pre-extraction ordering), so + a failure in the day-complete DB reads cannot suppress the + completed event. Asserted by bus event ORDER, not just counts.""" + entry, child, instances = await _setup_and_seed(hass, make_entry) + for instance in instances: + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + ordered = [ + etype for etype, _payload in hass.bus.events + if etype in (EVENT_QUEST_COMPLETED, EVENT_CHILD_DAY_COMPLETE) + ] + # The completed event must precede the day-complete event. + first_completed = ordered.index(EVENT_QUEST_COMPLETED) + first_day = ordered.index(EVENT_CHILD_DAY_COMPLETE) + assert first_completed < first_day, ( + f"completed must fire before day-complete; order was {ordered}" + ) + + +# --------------------------------------------------------------------------- +# Day-complete rule coverage (P2.2b): an explicit ``today`` proves the +# four outcomes — zero-quest day, non-today instance, partial day, and +# fully-cleared today. +# --------------------------------------------------------------------------- + + +async def test_day_complete_zero_quest_day_yields_no_event( + hass, make_entry +) -> None: + """A zero-quest day never fires day-complete: when the completed + payload's ``due_date`` matches ``today`` but the child has NO + instances due that date, the builder returns ``None`` (the + all-done binary sensor's rule — a day with zero quests owed can + never be "cleared").""" + import datetime + + from custom_components.nestquest.core.events import ( + build_child_day_complete_event, + build_quest_completed_event, + ) + + entry, _child, instances = await _setup_and_seed(hass, make_entry) + database = entry.runtime_data.database + # A date the daily rule does NOT cover (well past the materialized + # horizon) — zero instances due this date. + zero_quest_day = datetime.date.today() + datetime.timedelta(days=365) + _etype, completed_payload = await build_quest_completed_event( + database, instances[0].id, was_on_time=True + ) + # Override due_date to the zero-quest day so the builder evaluates + # it (otherwise the due_date != today short-circuit fires first). + crafted = dict(completed_payload, due_date=zero_quest_day.isoformat()) + result = await build_child_day_complete_event( + database, crafted, today=zero_quest_day + ) + assert result is None, ( + "a zero-quest day must not yield a day-complete event" + ) + + +async def test_day_complete_non_today_instance_yields_no_event( + hass, make_entry +) -> None: + """Completing an instance NOT due today never evaluates the + child's day-complete for today: only the completed event fires.""" + import datetime + + from custom_components.nestquest.core.events import ( + build_child_day_complete_event, + build_quest_completed_event, + ) + + entry, _child, instances = await _setup_and_seed(hass, make_entry) + database = entry.runtime_data.database + # Complete a FUTURE instance (tomorrow), pass today = today. + from custom_components.nestquest.dao_instances import QuestInstancesDao + + today = datetime.date.today() + end = (today + datetime.timedelta(days=3)).isoformat() + all_instances = await QuestInstancesDao(database).list_by_date_range( + _child.id, today.isoformat(), end + ) + today_ids = {i.id for i in instances} + tomorrow_instance = next(i for i in all_instances if i.id not in today_ids) + _etype, completed_payload = await build_quest_completed_event( + database, tomorrow_instance.id, was_on_time=True + ) + assert completed_payload["due_date"] != today.isoformat() + result = await build_child_day_complete_event( + database, completed_payload, today=today + ) + assert result is None, ( + "a non-today instance must not yield a day-complete event for today" + ) + + +async def test_day_complete_partial_day_yields_no_event( + hass, make_entry +) -> None: + """A partial day (some quests still open) yields only the + completed event — day-complete does not fire until the WHOLE day + clears.""" + import datetime + + from custom_components.nestquest.core.events import ( + build_child_day_complete_event, + build_quest_completed_event, + ) + from custom_components.nestquest.completion import complete_instance + + entry, child, instances = await _setup_and_seed( + hass, make_entry, windows=("morning", "evening") + ) + assert len(instances) == 2 + database = entry.runtime_data.database + today = datetime.date.today() + # Complete ONLY the morning instance — the evening one stays open. + await complete_instance( + database, + instances[0].id, + actor_source="panel", + actor_child_id=child.id, + today=today, + ) + _etype, completed_payload = await build_quest_completed_event( + database, instances[0].id, was_on_time=True + ) + result = await build_child_day_complete_event( + database, completed_payload, today=today + ) + assert result is None, ( + "a partial day (evening quest still open) must not fire day-complete" + ) + + +async def test_day_complete_fully_cleared_today_yields_event( + hass, make_entry +) -> None: + """A fully-cleared today (all owed quests done) yields the + completed event AND the child_day_complete event.""" + import datetime + + from custom_components.nestquest.core.events import ( + build_child_day_complete_event, + build_quest_completed_event, + ) + from custom_components.nestquest.completion import complete_instance + + entry, child, instances = await _setup_and_seed( + hass, make_entry, windows=("morning", "evening") + ) + assert len(instances) == 2 + database = entry.runtime_data.database + today = datetime.date.today() + for instance in instances: + await complete_instance( + database, + instance.id, + actor_source="panel", + actor_child_id=child.id, + today=today, + ) + # The last completion's payload drives the day-complete evaluation. + _etype, completed_payload = await build_quest_completed_event( + database, instances[-1].id, was_on_time=True + ) + result = await build_child_day_complete_event( + database, completed_payload, today=today + ) + assert result is not None, ( + "a fully-cleared today must yield the day-complete event" + ) + assert result[0] == EVENT_CHILD_DAY_COMPLETE + assert result[1]["quests_due"] == len(instances) + assert result[1]["quests_completed"] == len(instances) + + +# --------------------------------------------------------------------------- +# Shim timezone wiring (P2.2c): the conftest fake hass defaults +# hass.config.time_zone to UTC, so the day-complete rule's HA-local +# ``today`` wiring is otherwise untested. This test sets the zone to +# one whose current local date differs from UTC and asserts the +# day-complete rule uses THAT local date, not UTC. +# --------------------------------------------------------------------------- + + +async def test_shim_day_complete_uses_ha_local_timezone( + hass, make_entry +) -> None: + """The day-complete rule reads ``hass.config.time_zone`` and + derives ``today`` in HA-local time, not UTC. + + Picks a zone whose current local date differs from UTC, seeds an + instance for THAT zone's today (not UTC's today), completes it + through the service path, and asserts + ``nestquest_child_day_complete`` fires — proving the local date + reached the day-complete rule. If no candidate zone has a + different date right now (only possible inside the ~1h window + around 12:00 UTC when all zones share one date), the test skips + rather than run vacuously. + """ + import datetime as _dt + from zoneinfo import ZoneInfo + + utc_today = _dt.datetime.now(_dt.timezone.utc).date() + far_zone = None + far_today = None + for tz_name in ( + "Pacific/Kiritimati", # +14 (UTC+14) + "Pacific/Auckland", # +12/+13 + "Pacific/Chatham", # +12:45/+13:45 + "Pacific/Honolulu", # -10 + "America/Los_Angeles", # -8/-7 + "Etc/GMT+12", # -12 + "Etc/GMT-13", # +13 + ): + local_today = _dt.datetime.now(ZoneInfo(tz_name)).date() + if local_today != utc_today: + far_zone = tz_name + far_today = local_today + break + if far_zone is None: + import pytest + + pytest.skip( + "no candidate zone has a date different from UTC right now" + ) + + hass.config.time_zone = far_zone + entry, child, instances = await _setup_and_seed( + hass, make_entry, seed_date=far_today + ) + for instance in instances: + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) + assert len(day_complete) == 1, ( + f"day-complete must fire under {far_zone} (local today " + f"{far_today.isoformat()}, UTC today {utc_today.isoformat()}); " + f"got {len(day_complete)} events" + ) diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 0c60076..e774f75 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -1475,7 +1475,7 @@ async def _collect_records(database, child_ids, start, end) -> set: def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None: - import custom_components.nestquest.materialize as materialize_module + import custom_components.nestquest.core.materialize as materialize_module # Give each materialize run a DISTINCT batch stamp: production upserts # rewrite generated_at on every re-run, so a shared pinned stamp would @@ -1487,6 +1487,9 @@ def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None: stamps["n"] += 1 return f"2026-06-01T00:00:{stamps['n']:02d}+00:00" + # The materialize() walk reads ``_now_stamp`` from its OWN module + # globals (now the HA-free core package), so the patch must target + # the core module, not the re-export shim the integration imports. monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp) async def _body(database): diff --git a/tests/test_presence.py b/tests/test_presence.py index 14db444..dd33184 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -364,11 +364,19 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None: def test_module_uses_no_iso_week_functions() -> None: - """D-004 guardrail: cycle math never reads ISO week numbers.""" + """D-004 guardrail: cycle math never reads ISO week numbers. + + Scanned against ``custom_components.nestquest.core.presence`` (the + real presence model since task 510c1f78); the integration's + ``presence.py`` is now a re-export shim, so scanning the shim is + vacuous. Mirrors ``test_recurrence_model.py``'s scan of + ``core.recurrence``. + """ from pathlib import Path source = Path( - __import__("custom_components.nestquest.presence", fromlist=["__file__"]).__file__ + __import__("custom_components.nestquest.core.presence", + fromlist=["__file__"]).__file__ ).read_text() assert "isocalendar" not in source assert "isoweek" not in source diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py index d0f1ea8..323c7c3 100644 --- a/tests/test_startup_db.py +++ b/tests/test_startup_db.py @@ -442,6 +442,11 @@ def test_package_never_unlinks_or_truncates_db_files() -> None: """Guardrail: no code path may delete or overwrite an existing database file. Scans the package for unlink/rmtree/write on the DB filename constant or *.db patterns. + + Recursive (``rglob``) so the bundled ``core/`` package (extracted + in task d0b691d8 / 510c1f78) is scanned alongside the integration's + own modules — a non-recursive ``glob`` skips ``core/`` and leaves + the guardrail vacuous against it. """ import re from pathlib import Path @@ -459,7 +464,7 @@ def test_package_never_unlinks_or_truncates_db_files() -> None: re.IGNORECASE, ) offenders: list[str] = [] - for py in sorted(package.glob("*.py")): + for py in sorted(package.rglob("*.py")): if pattern.search(py.read_text()): offenders.append(py.name) assert offenders == [], (