Extract the engines and business layer into core/ (task 510c1f78)

Move presence.py, materialize.py, completion.py, events.py, children.py,
quest_definitions.py, admin_allowlist.py and service_policy.py into
custom_components/nestquest/core/, leaving re-export shims at the
original paths so the integration keeps working unchanged.

core/events.py is refactored to PAYLOAD CONSTRUCTION ONLY: it builds
and returns (event_type, payload) tuples and never touches hass.bus or
hass.config. The HA-local 'today' the day-complete rule needs is now a
parameter (read from hass.config.time_zone by the integration shim).
The events.py shim retains fire_quest_completed/fire_quest_uncompleted,
wiring the returned events to hass.bus.async_fire so the four event
names and payload shapes stay identical (nestquest_quest_completed with
was_on_time, nestquest_quest_uncompleted, nestquest_child_day_complete
with quests_due/quests_completed, occurred_at strict UTC ISO-8601).

Update two test logger-name references (children now logs as
...core.children) and the materialize _now_stamp monkeypatch target
(now patches the core module the walk reads globals from).

1497 passed (baseline). No homeassistant import under core/, no
top-level core import under the integration, no repo-root core/.
This commit is contained in:
2026-09-21 14:52:58 -04:00
parent 1503774e85
commit 08cb31ce4c
18 changed files with 2726 additions and 2560 deletions
+7 -149
View File
@@ -1,153 +1,11 @@
"""Business layer for the admin allowlist (Feature 03).
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
Sits between the options flow / Feature 09 service gate and the typed
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
Assistant user IDs — never usernames or display names (feature
guardrail) — and an empty allowlist must fail closed: every function
here refuses to write an empty allowlist, so the owner can never be
accidentally locked out of admin operations by a bad save.
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
in one write are rejected up front — they would silently map to one
row and hide a caller bug. Writes replace the allowlist atomically:
the diff (additions and removals) is computed and applied inside one
transaction under the connection-scoped lock shared with the DAOs, so
a concurrent reader never observes a half-applied sync.
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
when it is EMPTY, so re-running setup never resurrects a deliberately
narrowed list. The setup user (the HA user who completed the config
flow, via the entry context) is added automatically so the owner is
never locked out on first setup; a stored entry-data copy of the list
re-seeds a lost database.
The admin allowlist business layer lives in
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
task 510c1f78); this module re-exports it so existing
``from .admin_allowlist import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
import datetime
import logging
from .dao_children import AdminUsersDao, _connection_lock
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
def _now_stamp() -> str:
"""UTC second-precision stamp, matching the registry's policy."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_user_id(value: object) -> str:
"""Return ``value`` trimmed; raise unless a non-empty string."""
if not isinstance(value, str):
raise ValueError(
f"admin user id must be a string, got {value!r}"
)
trimmed = value.strip()
if not trimmed:
raise ValueError("admin user id must not be empty")
return trimmed
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
"""Return the allowlisted HA user IDs in storage order."""
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
async def set_admin_ids(
database: NestQuestDatabase, user_ids: list[str]
) -> None:
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
Refuses an empty list — an empty allowlist must fail closed, and
the only way to reach one through this API would be removing the
last admin, so that removal is refused here with a clear error.
Refuses non-string, empty, or duplicate ids up front. The diff
against the current allowlist (additions and removals) applies
inside one transaction under the connection lock.
"""
if not isinstance(user_ids, list):
raise ValueError("user_ids must be a list of HA user id strings")
normalized: list[str] = []
for value in user_ids:
normalized.append(_validate_user_id(value))
if len(set(normalized)) != len(normalized):
raise ValueError("user_ids must not contain duplicates")
if not normalized:
raise ValueError(
"refusing to empty the admin allowlist: at least one admin "
"must remain (an empty allowlist fails closed)"
)
dao = AdminUsersDao(database)
async with _connection_lock(database):
async with database.transaction():
current = {row.ha_user_id for row in await dao.list()}
target = set(normalized)
for user_id in sorted(current - target):
await dao.remove(user_id)
for user_id in normalized:
await dao.add(user_id, _now_stamp())
async def is_admin(
database: NestQuestDatabase, user_id: object
) -> bool:
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
True only for an allowlisted user id. Everything else is False:
a non-allowlisted id, an unknown id, a None or non-string value
(a service call with no user context), and an empty allowlist
(nobody is admin rather than everybody). The Feature 09 gate
wraps every admin-only service with this verdict; it never raises
for malformed input — malformed means not admin.
"""
if not isinstance(user_id, str) or not user_id:
return False
return await AdminUsersDao(database).exists(user_id)
async def seed_setup_admin(
database: NestQuestDatabase,
*,
stored_admin_ids: list[str] | None = None,
context_user_id: str | None = None,
owner_ids: list[str] | None = None,
) -> list[str]:
"""Seed an empty allowlist so the owner is never locked out.
Only acts when the allowlist is EMPTY. Preference order: the
stored persisted copy of the list (the options flow's current
selection, then the config flow's original — disaster recovery for
a lost database), then the HA user who completed the config flow
(``context_user_id``), then — when no caller identity survived at
all — every Home Assistant OWNER account (``owner_ids``), so a
first setup whose flow context lost the user id still seeds a
working admin. Returns the allowlist after seeding. Non-empty
allowlists are returned untouched — re-running setup must never
resurrect a deliberately narrowed list.
"""
dao = AdminUsersDao(database)
async with _connection_lock(database):
current = [row.ha_user_id for row in await dao.list()]
if current:
return current
candidates: list[str] = []
if stored_admin_ids is not None:
if not isinstance(stored_admin_ids, list):
raise ValueError(
"stored_admin_ids must be a list of HA user id strings"
)
candidates = [_validate_user_id(value) for value in stored_admin_ids]
elif context_user_id is not None:
candidates = [_validate_user_id(context_user_id)]
elif owner_ids:
if not isinstance(owner_ids, list):
raise ValueError(
"owner_ids must be a list of HA user id strings"
)
candidates = [_validate_user_id(value) for value in owner_ids]
for user_id in candidates:
await dao.add(user_id, _now_stamp())
return [row.ha_user_id for row in await dao.list()]
from .core.admin_allowlist import * # noqa: F401,F403
+7 -276
View File
@@ -1,280 +1,11 @@
"""Business layer for the children registry (Feature 03).
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
Sits between the Feature 09 service gate and the typed
:class:`~.dao_children.ChildrenDao`: callers get validation, name
policies and the duplicate-name warning here, and never talk to the
DAO or SQL directly. Children are never hard-deleted — deactivation
is a separate explicit operation (:func:`set_child_active`), so
completion history keeps its references (feature guardrail).
Name policy: the display name is required and trimmed; an empty or
whitespace-only name is rejected with ValueError. A duplicate display
name is ALLOWED (two children may legitimately share a nickname) but
logged as a warning so an accidental double-add is visible in the HA
log. The duplicate check is case-insensitive and whitespace-normalised
on both sides, because "ada" and "Ada " are the same name to a parent.
Colour and avatar are optional free-form strings, trimmed when present;
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
optional field back to NULL is deliberately rejected: the DAO's update
writes only provided fields (it cannot express NULL), so the business
layer refuses an explicit ``None`` instead of silently ignoring it.
Timestamps follow the module timestamp policy: the business layer
stamps UTC ISO-8601 at second precision so every row it creates is
uniform.
The children registry business layer lives in
:mod:`custom_components.nestquest.core.children` (extracted in
task 510c1f78); this module re-exports it so existing
``from .children import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import datetime
import logging
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
#: in :func:`edit_child`.
_UNSET = object()
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_text(
value: object, field: str, *, required: bool = False
) -> str | None:
"""Validate a free-text field and return it trimmed.
``None`` raises when ``required`` and otherwise returns None. A
non-string value is rejected; an empty result is rejected for
required fields (whitespace-only names are empty to a parent).
"""
if value is None:
if required:
raise ValueError(f"{field} is required")
return None
if not isinstance(value, str):
raise ValueError(f"{field} must be a string, got {value!r}")
trimmed = value.strip()
if required and not trimmed:
raise ValueError(f"{field} is required")
return trimmed
def _validate_sort_order(value: object) -> int:
"""Reject non-int sort orders (bools included) before the DAO."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"sort_order must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def _warn_on_duplicate_name(
database: NestQuestDatabase,
display_name: str,
*,
exclude_child_id: int | None = None,
) -> None:
"""Log a warning when ``display_name`` duplicates an existing child.
Case-insensitive and whitespace-normalised on both sides. The
duplicate is still allowed — this only makes it visible; the log
line names both rows so an accidental double-add can be found.
``exclude_child_id`` removes the child being edited from the scan,
so an edit only warns when the result collides with a DIFFERENT
child's name — including case-only edits, where the self-comparison
would otherwise hide a real duplicate.
"""
lowered = display_name.casefold()
for child in await ChildrenDao(database).list_all():
if exclude_child_id is not None and child.id == exclude_child_id:
continue
if child.display_name.strip().casefold() == lowered:
LOGGER.warning(
"Child display name %r duplicates existing child %d "
"(%r); allowed, but check this was intentional",
display_name,
child.id,
child.display_name,
)
return
async def create_child(
database: NestQuestDatabase,
display_name: str,
*,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int = 0,
) -> ChildRecord:
"""Create a child profile and return the stored record.
The display name is required, trimmed, and rejected when empty.
A duplicate display name (case-insensitive) is allowed but logged
as a warning. The scan and the INSERT run inside the
connection-scoped lock shared with the DAOs, so a concurrent
create of the same normalised name queues behind the first insert
and sees it — the duplicate still warns; it can never slip through
unlogged.
"""
name = _validate_text(display_name, "display_name", required=True)
assert name is not None
colour_value = _validate_text(colour, "colour")
avatar_value = _validate_text(avatar_ref, "avatar_ref")
order = _validate_sort_order(sort_order)
async with _connection_lock(database):
await _warn_on_duplicate_name(database, name)
created = await ChildrenDao(database).create(
name,
_now_stamp(),
colour=colour_value,
avatar_ref=avatar_value,
sort_order=order,
)
return created
async def edit_child(
database: NestQuestDatabase,
child_id: int,
*,
display_name: str | object = _UNSET,
colour: str | None | object = _UNSET,
avatar_ref: str | None | object = _UNSET,
sort_order: int | object = _UNSET,
) -> ChildRecord:
"""Edit a child profile and return the updated record.
Arguments default to the module sentinel ``_UNSET`` meaning "leave
this field alone". An explicitly passed ``None`` for an optional
field raises: the DAO's update writes only provided fields and
cannot express NULL, so a silent no-op would mask the caller's
intent (see the module docstring). A provided display name is
validated like create's; the duplicate scan excludes only the child
being edited, so a case-only edit colliding with another child's
normalised name still warns. Raises ValueError when the child does
not exist.
Atomicity: the existence check, the duplicate scan, the UPDATE and
the readback all run inside the connection-scoped lock shared with
the DAOs, so another edit cannot slip between this call's UPDATE
and its readback — the returned record is exactly the one this
edit produced, not a later writer's.
"""
_validate_child_id(child_id)
async with _connection_lock(database):
dao = ChildrenDao(database)
existing = await dao.get(child_id)
if existing is None:
raise ValueError(f"child {child_id} does not exist")
updates: dict[str, object] = {}
if display_name is not _UNSET:
name = _validate_text(
display_name, "display_name", required=True
)
assert name is not None
await _warn_on_duplicate_name(
database, name, exclude_child_id=child_id
)
updates["display_name"] = name
if colour is not _UNSET:
value = _validate_text(colour, "colour")
if value is None:
raise ValueError(
"clearing colour is not supported; pass a replacement "
"value or omit the field"
)
updates["colour"] = value
if avatar_ref is not _UNSET:
value = _validate_text(avatar_ref, "avatar_ref")
if value is None:
raise ValueError(
"clearing avatar_ref is not supported; pass a "
"replacement value or omit the field"
)
updates["avatar_ref"] = value
if sort_order is not _UNSET:
updates["sort_order"] = _validate_sort_order(sort_order)
if updates:
await dao.update(child_id, **updates)
edited = await dao.get(child_id)
assert edited is not None
return edited
async def set_child_active(
database: NestQuestDatabase, child_id: int, is_active: bool
) -> ChildRecord:
"""Deactivate or reactivate a child; returns the updated record.
Deactivation hides the child from the panel without deleting their
history (feature guardrail); this is the only removal path.
``is_active`` must be a real bool: the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError when the
child does not exist.
Atomicity: existence check, UPDATE and readback run inside the
connection-scoped lock, so a concurrent opposite transition cannot
make this call report the other caller's value.
"""
_validate_child_id(child_id)
async with _connection_lock(database):
dao = ChildrenDao(database)
if await dao.get(child_id) is None:
raise ValueError(f"child {child_id} does not exist")
if not isinstance(is_active, bool):
raise ValueError(
f"is_active must be a real bool, got {is_active!r}"
)
await dao.set_active(child_id, is_active)
updated = await dao.get(child_id)
assert updated is not None
return updated
async def reorder_children(
database: NestQuestDatabase, ordered_ids: list[int]
) -> None:
"""Rewrite the children's panel display order in one transaction.
``ordered_ids`` must be a complete permutation of the children
table — every child id exactly once. A partial list (missing an
existing child), an unknown id, or a duplicate is rejected with
ValueError before any write, so the current order survives a
rejected call. The DAO enforces this inside its own transaction
(check and write are atomic under the connection lock); this
wrapper additionally rejects non-integer ids up front, since
``bool`` and floats would silently compare against stored integer
ids at the SQL layer.
"""
for child_id in ordered_ids:
_validate_child_id(child_id)
await ChildrenDao(database).reorder(ordered_ids)
async def list_children(
database: NestQuestDatabase, *, active_only: bool = False
) -> list[ChildRecord]:
"""Return children in display order (sort_order, then id)."""
dao = ChildrenDao(database)
if active_only:
return await dao.list_active()
return await dao.list_all()
from .core.children import * # noqa: F401,F403
+7 -545
View File
@@ -1,549 +1,11 @@
"""Business layer for the completion event log (Feature 08).
"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package.
Sits between the Feature 09 service gate and the typed
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
validation and the UTC occurred-at stamp here, and never talk to the
DAO or SQL directly. This module is the only Feature-08 caller of
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
instance (no-op if already done) is :func:`complete_instance`;
un-completing (no-op if already open) is :func:`uncomplete_instance`.
Instance current state is derived from the latest event via
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
no status column. ``missed`` is derived for past-due open instances
(``due_date`` before HA-local ``today``) and is never stored as an
event_type.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'``
requires ``actor_child_id`` (the tapped profile) and forbids
``actor_user_id``. Inverted shapes raise a field-naming ValueError
before any write.
``occurred_at`` is stamped UTC ISO-8601 at second precision with an
explicit ``+00:00`` offset, matching the DAO's one accepted shape so
lexicographic range queries stay exact. ``was_on_time`` is required
(True/False) for completed events; un-completions may pass None. There
is no permission check here (that is Feature 09).
The completion business layer lives in
:mod:`custom_components.nestquest.core.completion` (extracted in
task 510c1f78); this module re-exports it so existing
``from .completion import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import asyncio
import datetime
from .dao_children import _CONNECTION_LOCKS, _connection_lock
from .dao_instances import (
EVENT_COMPLETED,
EVENT_UNCOMPLETED,
CompletionEventRecord,
CompletionEventsDao,
QuestInstanceRecord,
QuestInstancesDao,
)
from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
def _stamp_occurred_at(now: datetime.datetime | None) -> str:
"""Return a DAO-accepted UTC stamp for ``now``, or the current time."""
if now is None:
return _now_stamp()
if not isinstance(now, datetime.datetime):
raise ValueError(f"now must be a datetime, got {now!r}")
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
return now.astimezone(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
def _completion_moment(
now: datetime.datetime | None,
today: datetime.date | None,
) -> tuple[datetime.date, datetime.time | None]:
"""Return the HA-local completion date and optional wall-clock time.
``today`` is the HA-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware HA-local datetime; its wall
clock is used as-is and is never converted to UTC. When ``today``
is omitted the date comes from ``now``. When both are omitted the
host calendar date is the Feature 07 fallback — never the UTC
clock. Time-of-day is None unless ``now`` is supplied; comparing
against ``due_time`` requires ``now``.
"""
if now is not None:
if not isinstance(now, datetime.datetime):
raise ValueError(f"now must be a datetime, got {now!r}")
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
if today is not None:
completion_date = today
elif now is not None:
completion_date = now.date()
else:
completion_date = datetime.date.today()
if now is None:
return completion_date, None
return completion_date, now.time()
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
def _derived_state(
instance: QuestInstanceRecord,
latest: CompletionEventRecord | None,
today: datetime.date,
) -> str:
"""Return ``open``, ``done``, or ``missed`` from event plus due date.
A latest completed event is ``done`` even when past due. No events
or a latest uncompleted event is ``open``, or ``missed`` when
``due_date`` is before ``today``. ``missed`` is never an event_type.
"""
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
due_date = datetime.date.fromisoformat(instance.due_date)
if due_date < today:
return "missed"
return "open"
if latest.event_type == EVENT_COMPLETED:
return "done"
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
def derive_state(
instance: QuestInstanceRecord,
latest: CompletionEventRecord | None,
today: datetime.date,
) -> str:
"""Public state derivation for the Feature 10 coordinator.
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
``missed`` from the latest event plus the due date (``missed`` is
derived, never an event_type). ``today`` is the caller-resolved
HA-local date.
"""
return _derived_state(instance, latest, today)
def _was_on_time(
instance: QuestInstanceRecord,
now: datetime.datetime | None,
today: datetime.date | None,
) -> bool:
"""Return whether the completion moment is on time for ``instance``.
On ``due_date`` before ``due_time`` is on time; on ``due_date``
after ``due_time``, or any later date, is late. With no
``due_time``, on time if the completion date equals ``due_date``
(or is earlier); late if after ``due_date``. Same-day
``due_time`` comparison requires ``now``; UTC time-of-day is never
substituted.
"""
completion_date, completion_time = _completion_moment(now, today)
due_date = datetime.date.fromisoformat(instance.due_date)
if completion_date < due_date:
return True
if completion_date > due_date:
return False
if instance.due_time is None:
return True
if completion_time is None:
raise ValueError("now is required to compare due_time")
due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time()
return completion_time <= due_time
class _TaskReentrantLock:
"""Shared connection lock the owning task may re-enter."""
def __init__(self, inner: asyncio.Lock) -> None:
self._inner = inner
self._task: asyncio.Task | None = None
self._depth = 0
def locked(self) -> bool:
return self._inner.locked()
async def acquire(self) -> bool:
task = asyncio.current_task()
if self._task is task:
self._depth += 1
return True
await self._inner.acquire()
self._task = task
self._depth = 1
return True
def release(self) -> None:
if self._task is not asyncio.current_task():
raise RuntimeError("lock released by a non-owner")
self._depth -= 1
if self._depth == 0:
self._task = None
self._inner.release()
async def __aenter__(self) -> _TaskReentrantLock:
await self.acquire()
return self
async def __aexit__(self, *exc: object) -> None:
self.release()
def _reentrant_connection_lock(database) -> _TaskReentrantLock:
"""Return the shared connection lock, made task-reentrant.
``CompletionEventsDao.append`` also acquires this lock, so
check+append in :func:`complete_instance` and
:func:`uncomplete_instance` must re-enter on the same task. The
wrapper is stored in the shared dict so the DAO sees the same
object.
"""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _CONNECTION_LOCKS.get(key)
if isinstance(lock, _TaskReentrantLock):
return lock
wrapped = _TaskReentrantLock(
lock if lock is not None else asyncio.Lock()
)
_CONNECTION_LOCKS[key] = wrapped
return wrapped
def _validate_int_id(value: object, field: str) -> int:
"""Reject non-int ids (bools included) before any lookup."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{field} must be an integer, got {value!r}")
return value
def _validate_event_type(value: object) -> str:
"""Return a schema-legal event type, or raise naming the field."""
if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {value!r}"
)
return value
def _validate_actor(
actor_source: object,
actor_user_id: object,
actor_child_id: object,
) -> tuple[str, str | None, int | None]:
"""Return a schema-legal actor triple, or raise naming the field.
``'user'`` requires a non-empty ``actor_user_id`` string and forbids
``actor_child_id``. ``'panel'`` requires an integer
``actor_child_id`` and forbids ``actor_user_id``. Any other
``actor_source`` (including ``'service'``) is rejected.
"""
if actor_source not in ("user", "panel"):
raise ValueError(
f"actor_source must be 'user' or 'panel', got {actor_source!r}"
)
if actor_source == "user":
if not isinstance(actor_user_id, str) or not actor_user_id.strip():
raise ValueError("actor_source 'user' requires actor_user_id")
if actor_child_id is not None:
raise ValueError(
"actor_source 'user' must not carry actor_child_id"
)
return "user", actor_user_id.strip(), None
if actor_user_id is not None:
raise ValueError("actor_source 'panel' must not carry actor_user_id")
if actor_child_id is None:
raise ValueError(
"actor_source 'panel' requires actor_child_id"
)
return (
"panel",
None,
_validate_int_id(actor_child_id, "actor_child_id"),
)
def _validate_was_on_time(
event_type: str, was_on_time: object
) -> bool | None:
"""Return a real bool or None; completed events require True/False."""
if was_on_time is not None and type(was_on_time) is not bool:
raise ValueError(
f"was_on_time must be True, False or None, got {was_on_time!r}"
)
if event_type == EVENT_COMPLETED and was_on_time is None:
raise ValueError(
"a completed event requires was_on_time (True/False)"
)
return was_on_time
async def append_event(
database: NestQuestDatabase,
instance_id: int,
child_id: int,
event_type: str,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
was_on_time: bool | None = None,
now: datetime.datetime | None = None,
) -> CompletionEventRecord:
"""Append one completion or un-completion event and return the row.
Validates actor shapes and the completed-event on-time requirement
before calling :meth:`CompletionEventsDao.append`. ``occurred_at``
is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
child_id = _validate_int_id(child_id, "child_id")
event_type = _validate_event_type(event_type)
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
was_on_time = _validate_was_on_time(event_type, was_on_time)
occurred_at = _stamp_occurred_at(now)
return await CompletionEventsDao(database).append(
instance_id,
child_id,
event_type,
actor_source,
occurred_at,
was_on_time,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
)
async def instance_state(
database: NestQuestDatabase,
instance_id: int,
*,
today: datetime.date | None = None,
) -> str:
"""Return ``open``, ``done``, or ``missed`` from the latest event.
No events, or a latest event of uncompleted, is ``open`` unless
``due_date`` is before ``today`` (HA-local, threaded), in which
case it is ``missed``. A latest event of completed is ``done``
even when past due. ``missed`` is never stored as an event_type.
Unknown ``instance_id`` raises ValueError naming the field.
Existence and the latest event are read inside the connection lock
so the answer is one snapshot.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
today_date = _resolve_today(today)
async with _connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
return _derived_state(instance, latest, today_date)
class CompletionResult(str):
"""The derived instance state, carrying the append verdict.
A ``str`` subclass so every existing ``result == "done"`` contract
keeps working; ``appended`` reports whether THIS call actually
appended an event (decided under the same lock as the append), so
a caller — the Feature 09 service path firing bus events —
announces a transition exactly once even under a concurrent
duplicate call where the loser's write is a no-op. A completion
also carries the ``was_on_time`` verdict computed under that same
lock, so the event payload never re-reads mutable latest state a
racing reversal could have replaced.
"""
appended: bool
was_on_time: bool | None
def __new__(
cls,
state: str,
*,
appended: bool,
was_on_time: bool | None = None,
) -> "CompletionResult":
obj = super().__new__(cls, state)
obj.appended = appended
obj.was_on_time = was_on_time
return obj
async def complete_instance(
database: NestQuestDatabase,
instance_id: int,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> CompletionResult:
"""Append a completed event unless the instance is already done.
Returns the derived state ``done``; ``appended`` reports whether
THIS call appended (False on an already-done no-op). Unknown
``instance_id`` raises ValueError naming the field. Existence,
latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append.
``was_on_time`` is computed from the HA-local completion moment
(threaded ``now`` / ``today``) against the instance ``due_date``
and optional ``due_time``. ``now`` is the timezone-aware HA-local
datetime; its wall clock is never converted to UTC. Same-day
``due_time`` comparison requires ``now``.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
async with _reentrant_connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
if latest is not None and latest.event_type == EVENT_COMPLETED:
return CompletionResult("done", appended=False)
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
# Computed under the completion lock and carried on the result:
# the bus event must report THIS completion's timing, never a
# re-read of the latest event a racing reversal could replace.
on_time = _was_on_time(instance, now, today)
await append_event(
database,
instance.id,
instance.child_id,
EVENT_COMPLETED,
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=on_time,
now=now,
)
return CompletionResult("done", appended=True, was_on_time=on_time)
async def uncomplete_instance(
database: NestQuestDatabase,
instance_id: int,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> CompletionResult:
"""Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``, or ``missed`` when the instance
is past due (``due_date`` before HA-local ``today``); ``appended``
reports whether THIS call appended (False on an already-open
no-op). Unknown ``instance_id`` raises ValueError naming the
field. Existence, latest-event, and the append run under one
connection lock so a concurrent uncomplete cannot double-append.
The original completed row is not modified.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
today_date = _resolve_today(today)
async with _reentrant_connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
return CompletionResult(
_derived_state(instance, latest, today_date),
appended=False,
)
if latest.event_type != EVENT_COMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
latest = await append_event(
database,
instance.id,
instance.child_id,
EVENT_UNCOMPLETED,
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=None,
now=now,
)
return CompletionResult(
_derived_state(instance, latest, today_date), appended=True
)
async def list_missed_for_child(
database: NestQuestDatabase,
child_id: int,
range_start: str,
range_end: str,
*,
today: datetime.date | None = None,
) -> list[QuestInstanceRecord]:
"""Return the child's missed instances due in the closed date range.
Missed means the latest event state is open and ``due_date`` is
before ``today`` (HA-local, threaded). Uses
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
Never appends a missed event. Instances and latest events are
read inside the connection lock so the list is one snapshot.
"""
child_id = _validate_int_id(child_id, "child_id")
today_date = _resolve_today(today)
async with _connection_lock(database):
instances = await QuestInstancesDao(database).list_by_date_range(
child_id, range_start, range_end
)
events = CompletionEventsDao(database)
missed: list[QuestInstanceRecord] = []
for instance in instances:
latest = await events.get_latest_for_instance(instance.id)
if _derived_state(instance, latest, today_date) == "missed":
missed.append(instance)
return missed
from .core.completion import * # noqa: F401,F403
@@ -0,0 +1,153 @@
"""Business layer for the admin allowlist (Feature 03).
Sits between the options flow / Feature 09 service gate and the typed
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
Assistant user IDs — never usernames or display names (feature
guardrail) — and an empty allowlist must fail closed: every function
here refuses to write an empty allowlist, so the owner can never be
accidentally locked out of admin operations by a bad save.
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
in one write are rejected up front — they would silently map to one
row and hide a caller bug. Writes replace the allowlist atomically:
the diff (additions and removals) is computed and applied inside one
transaction under the connection-scoped lock shared with the DAOs, so
a concurrent reader never observes a half-applied sync.
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
when it is EMPTY, so re-running setup never resurrects a deliberately
narrowed list. The setup user (the HA user who completed the config
flow, via the entry context) is added automatically so the owner is
never locked out on first setup; a stored entry-data copy of the list
re-seeds a lost database.
"""
from __future__ import annotations
import datetime
import logging
from .dao_children import AdminUsersDao, _connection_lock
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
def _now_stamp() -> str:
"""UTC second-precision stamp, matching the registry's policy."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_user_id(value: object) -> str:
"""Return ``value`` trimmed; raise unless a non-empty string."""
if not isinstance(value, str):
raise ValueError(
f"admin user id must be a string, got {value!r}"
)
trimmed = value.strip()
if not trimmed:
raise ValueError("admin user id must not be empty")
return trimmed
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
"""Return the allowlisted HA user IDs in storage order."""
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
async def set_admin_ids(
database: NestQuestDatabase, user_ids: list[str]
) -> None:
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
Refuses an empty list — an empty allowlist must fail closed, and
the only way to reach one through this API would be removing the
last admin, so that removal is refused here with a clear error.
Refuses non-string, empty, or duplicate ids up front. The diff
against the current allowlist (additions and removals) applies
inside one transaction under the connection lock.
"""
if not isinstance(user_ids, list):
raise ValueError("user_ids must be a list of HA user id strings")
normalized: list[str] = []
for value in user_ids:
normalized.append(_validate_user_id(value))
if len(set(normalized)) != len(normalized):
raise ValueError("user_ids must not contain duplicates")
if not normalized:
raise ValueError(
"refusing to empty the admin allowlist: at least one admin "
"must remain (an empty allowlist fails closed)"
)
dao = AdminUsersDao(database)
async with _connection_lock(database):
async with database.transaction():
current = {row.ha_user_id for row in await dao.list()}
target = set(normalized)
for user_id in sorted(current - target):
await dao.remove(user_id)
for user_id in normalized:
await dao.add(user_id, _now_stamp())
async def is_admin(
database: NestQuestDatabase, user_id: object
) -> bool:
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
True only for an allowlisted user id. Everything else is False:
a non-allowlisted id, an unknown id, a None or non-string value
(a service call with no user context), and an empty allowlist
(nobody is admin rather than everybody). The Feature 09 gate
wraps every admin-only service with this verdict; it never raises
for malformed input — malformed means not admin.
"""
if not isinstance(user_id, str) or not user_id:
return False
return await AdminUsersDao(database).exists(user_id)
async def seed_setup_admin(
database: NestQuestDatabase,
*,
stored_admin_ids: list[str] | None = None,
context_user_id: str | None = None,
owner_ids: list[str] | None = None,
) -> list[str]:
"""Seed an empty allowlist so the owner is never locked out.
Only acts when the allowlist is EMPTY. Preference order: the
stored persisted copy of the list (the options flow's current
selection, then the config flow's original — disaster recovery for
a lost database), then the HA user who completed the config flow
(``context_user_id``), then — when no caller identity survived at
all — every Home Assistant OWNER account (``owner_ids``), so a
first setup whose flow context lost the user id still seeds a
working admin. Returns the allowlist after seeding. Non-empty
allowlists are returned untouched — re-running setup must never
resurrect a deliberately narrowed list.
"""
dao = AdminUsersDao(database)
async with _connection_lock(database):
current = [row.ha_user_id for row in await dao.list()]
if current:
return current
candidates: list[str] = []
if stored_admin_ids is not None:
if not isinstance(stored_admin_ids, list):
raise ValueError(
"stored_admin_ids must be a list of HA user id strings"
)
candidates = [_validate_user_id(value) for value in stored_admin_ids]
elif context_user_id is not None:
candidates = [_validate_user_id(context_user_id)]
elif owner_ids:
if not isinstance(owner_ids, list):
raise ValueError(
"owner_ids must be a list of HA user id strings"
)
candidates = [_validate_user_id(value) for value in owner_ids]
for user_id in candidates:
await dao.add(user_id, _now_stamp())
return [row.ha_user_id for row in await dao.list()]
@@ -0,0 +1,280 @@
"""Business layer for the children registry (Feature 03).
Sits between the Feature 09 service gate and the typed
:class:`~.dao_children.ChildrenDao`: callers get validation, name
policies and the duplicate-name warning here, and never talk to the
DAO or SQL directly. Children are never hard-deleted — deactivation
is a separate explicit operation (:func:`set_child_active`), so
completion history keeps its references (feature guardrail).
Name policy: the display name is required and trimmed; an empty or
whitespace-only name is rejected with ValueError. A duplicate display
name is ALLOWED (two children may legitimately share a nickname) but
logged as a warning so an accidental double-add is visible in the HA
log. The duplicate check is case-insensitive and whitespace-normalised
on both sides, because "ada" and "Ada " are the same name to a parent.
Colour and avatar are optional free-form strings, trimmed when present;
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
optional field back to NULL is deliberately rejected: the DAO's update
writes only provided fields (it cannot express NULL), so the business
layer refuses an explicit ``None`` instead of silently ignoring it.
Timestamps follow the module timestamp policy: the business layer
stamps UTC ISO-8601 at second precision so every row it creates is
uniform.
"""
from __future__ import annotations
import datetime
import logging
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
#: in :func:`edit_child`.
_UNSET = object()
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_text(
value: object, field: str, *, required: bool = False
) -> str | None:
"""Validate a free-text field and return it trimmed.
``None`` raises when ``required`` and otherwise returns None. A
non-string value is rejected; an empty result is rejected for
required fields (whitespace-only names are empty to a parent).
"""
if value is None:
if required:
raise ValueError(f"{field} is required")
return None
if not isinstance(value, str):
raise ValueError(f"{field} must be a string, got {value!r}")
trimmed = value.strip()
if required and not trimmed:
raise ValueError(f"{field} is required")
return trimmed
def _validate_sort_order(value: object) -> int:
"""Reject non-int sort orders (bools included) before the DAO."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"sort_order must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def _warn_on_duplicate_name(
database: NestQuestDatabase,
display_name: str,
*,
exclude_child_id: int | None = None,
) -> None:
"""Log a warning when ``display_name`` duplicates an existing child.
Case-insensitive and whitespace-normalised on both sides. The
duplicate is still allowed — this only makes it visible; the log
line names both rows so an accidental double-add can be found.
``exclude_child_id`` removes the child being edited from the scan,
so an edit only warns when the result collides with a DIFFERENT
child's name — including case-only edits, where the self-comparison
would otherwise hide a real duplicate.
"""
lowered = display_name.casefold()
for child in await ChildrenDao(database).list_all():
if exclude_child_id is not None and child.id == exclude_child_id:
continue
if child.display_name.strip().casefold() == lowered:
LOGGER.warning(
"Child display name %r duplicates existing child %d "
"(%r); allowed, but check this was intentional",
display_name,
child.id,
child.display_name,
)
return
async def create_child(
database: NestQuestDatabase,
display_name: str,
*,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int = 0,
) -> ChildRecord:
"""Create a child profile and return the stored record.
The display name is required, trimmed, and rejected when empty.
A duplicate display name (case-insensitive) is allowed but logged
as a warning. The scan and the INSERT run inside the
connection-scoped lock shared with the DAOs, so a concurrent
create of the same normalised name queues behind the first insert
and sees it — the duplicate still warns; it can never slip through
unlogged.
"""
name = _validate_text(display_name, "display_name", required=True)
assert name is not None
colour_value = _validate_text(colour, "colour")
avatar_value = _validate_text(avatar_ref, "avatar_ref")
order = _validate_sort_order(sort_order)
async with _connection_lock(database):
await _warn_on_duplicate_name(database, name)
created = await ChildrenDao(database).create(
name,
_now_stamp(),
colour=colour_value,
avatar_ref=avatar_value,
sort_order=order,
)
return created
async def edit_child(
database: NestQuestDatabase,
child_id: int,
*,
display_name: str | object = _UNSET,
colour: str | None | object = _UNSET,
avatar_ref: str | None | object = _UNSET,
sort_order: int | object = _UNSET,
) -> ChildRecord:
"""Edit a child profile and return the updated record.
Arguments default to the module sentinel ``_UNSET`` meaning "leave
this field alone". An explicitly passed ``None`` for an optional
field raises: the DAO's update writes only provided fields and
cannot express NULL, so a silent no-op would mask the caller's
intent (see the module docstring). A provided display name is
validated like create's; the duplicate scan excludes only the child
being edited, so a case-only edit colliding with another child's
normalised name still warns. Raises ValueError when the child does
not exist.
Atomicity: the existence check, the duplicate scan, the UPDATE and
the readback all run inside the connection-scoped lock shared with
the DAOs, so another edit cannot slip between this call's UPDATE
and its readback — the returned record is exactly the one this
edit produced, not a later writer's.
"""
_validate_child_id(child_id)
async with _connection_lock(database):
dao = ChildrenDao(database)
existing = await dao.get(child_id)
if existing is None:
raise ValueError(f"child {child_id} does not exist")
updates: dict[str, object] = {}
if display_name is not _UNSET:
name = _validate_text(
display_name, "display_name", required=True
)
assert name is not None
await _warn_on_duplicate_name(
database, name, exclude_child_id=child_id
)
updates["display_name"] = name
if colour is not _UNSET:
value = _validate_text(colour, "colour")
if value is None:
raise ValueError(
"clearing colour is not supported; pass a replacement "
"value or omit the field"
)
updates["colour"] = value
if avatar_ref is not _UNSET:
value = _validate_text(avatar_ref, "avatar_ref")
if value is None:
raise ValueError(
"clearing avatar_ref is not supported; pass a "
"replacement value or omit the field"
)
updates["avatar_ref"] = value
if sort_order is not _UNSET:
updates["sort_order"] = _validate_sort_order(sort_order)
if updates:
await dao.update(child_id, **updates)
edited = await dao.get(child_id)
assert edited is not None
return edited
async def set_child_active(
database: NestQuestDatabase, child_id: int, is_active: bool
) -> ChildRecord:
"""Deactivate or reactivate a child; returns the updated record.
Deactivation hides the child from the panel without deleting their
history (feature guardrail); this is the only removal path.
``is_active`` must be a real bool: the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError when the
child does not exist.
Atomicity: existence check, UPDATE and readback run inside the
connection-scoped lock, so a concurrent opposite transition cannot
make this call report the other caller's value.
"""
_validate_child_id(child_id)
async with _connection_lock(database):
dao = ChildrenDao(database)
if await dao.get(child_id) is None:
raise ValueError(f"child {child_id} does not exist")
if not isinstance(is_active, bool):
raise ValueError(
f"is_active must be a real bool, got {is_active!r}"
)
await dao.set_active(child_id, is_active)
updated = await dao.get(child_id)
assert updated is not None
return updated
async def reorder_children(
database: NestQuestDatabase, ordered_ids: list[int]
) -> None:
"""Rewrite the children's panel display order in one transaction.
``ordered_ids`` must be a complete permutation of the children
table — every child id exactly once. A partial list (missing an
existing child), an unknown id, or a duplicate is rejected with
ValueError before any write, so the current order survives a
rejected call. The DAO enforces this inside its own transaction
(check and write are atomic under the connection lock); this
wrapper additionally rejects non-integer ids up front, since
``bool`` and floats would silently compare against stored integer
ids at the SQL layer.
"""
for child_id in ordered_ids:
_validate_child_id(child_id)
await ChildrenDao(database).reorder(ordered_ids)
async def list_children(
database: NestQuestDatabase, *, active_only: bool = False
) -> list[ChildRecord]:
"""Return children in display order (sort_order, then id)."""
dao = ChildrenDao(database)
if active_only:
return await dao.list_active()
return await dao.list_all()
@@ -0,0 +1,549 @@
"""Business layer for the completion event log (Feature 08).
Sits between the Feature 09 service gate and the typed
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
validation and the UTC occurred-at stamp here, and never talk to the
DAO or SQL directly. This module is the only Feature-08 caller of
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
instance (no-op if already done) is :func:`complete_instance`;
un-completing (no-op if already open) is :func:`uncomplete_instance`.
Instance current state is derived from the latest event via
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
no status column. ``missed`` is derived for past-due open instances
(``due_date`` before HA-local ``today``) and is never stored as an
event_type.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'``
requires ``actor_child_id`` (the tapped profile) and forbids
``actor_user_id``. Inverted shapes raise a field-naming ValueError
before any write.
``occurred_at`` is stamped UTC ISO-8601 at second precision with an
explicit ``+00:00`` offset, matching the DAO's one accepted shape so
lexicographic range queries stay exact. ``was_on_time`` is required
(True/False) for completed events; un-completions may pass None. There
is no permission check here (that is Feature 09).
"""
from __future__ import annotations
import asyncio
import datetime
from .dao_children import _CONNECTION_LOCKS, _connection_lock
from .dao_instances import (
EVENT_COMPLETED,
EVENT_UNCOMPLETED,
CompletionEventRecord,
CompletionEventsDao,
QuestInstanceRecord,
QuestInstancesDao,
)
from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
def _stamp_occurred_at(now: datetime.datetime | None) -> str:
"""Return a DAO-accepted UTC stamp for ``now``, or the current time."""
if now is None:
return _now_stamp()
if not isinstance(now, datetime.datetime):
raise ValueError(f"now must be a datetime, got {now!r}")
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
return now.astimezone(datetime.timezone.utc).strftime(
_UTC_TIMESTAMP_FORMAT
)
def _completion_moment(
now: datetime.datetime | None,
today: datetime.date | None,
) -> tuple[datetime.date, datetime.time | None]:
"""Return the HA-local completion date and optional wall-clock time.
``today`` is the HA-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware HA-local datetime; its wall
clock is used as-is and is never converted to UTC. When ``today``
is omitted the date comes from ``now``. When both are omitted the
host calendar date is the Feature 07 fallback — never the UTC
clock. Time-of-day is None unless ``now`` is supplied; comparing
against ``due_time`` requires ``now``.
"""
if now is not None:
if not isinstance(now, datetime.datetime):
raise ValueError(f"now must be a datetime, got {now!r}")
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
if today is not None:
completion_date = today
elif now is not None:
completion_date = now.date()
else:
completion_date = datetime.date.today()
if now is None:
return completion_date, None
return completion_date, now.time()
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the threaded HA-local date, or the host calendar date."""
if today is None:
return datetime.date.today()
return today
def _derived_state(
instance: QuestInstanceRecord,
latest: CompletionEventRecord | None,
today: datetime.date,
) -> str:
"""Return ``open``, ``done``, or ``missed`` from event plus due date.
A latest completed event is ``done`` even when past due. No events
or a latest uncompleted event is ``open``, or ``missed`` when
``due_date`` is before ``today``. ``missed`` is never an event_type.
"""
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
due_date = datetime.date.fromisoformat(instance.due_date)
if due_date < today:
return "missed"
return "open"
if latest.event_type == EVENT_COMPLETED:
return "done"
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
def derive_state(
instance: QuestInstanceRecord,
latest: CompletionEventRecord | None,
today: datetime.date,
) -> str:
"""Public state derivation for the Feature 10 coordinator.
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
``missed`` from the latest event plus the due date (``missed`` is
derived, never an event_type). ``today`` is the caller-resolved
HA-local date.
"""
return _derived_state(instance, latest, today)
def _was_on_time(
instance: QuestInstanceRecord,
now: datetime.datetime | None,
today: datetime.date | None,
) -> bool:
"""Return whether the completion moment is on time for ``instance``.
On ``due_date`` before ``due_time`` is on time; on ``due_date``
after ``due_time``, or any later date, is late. With no
``due_time``, on time if the completion date equals ``due_date``
(or is earlier); late if after ``due_date``. Same-day
``due_time`` comparison requires ``now``; UTC time-of-day is never
substituted.
"""
completion_date, completion_time = _completion_moment(now, today)
due_date = datetime.date.fromisoformat(instance.due_date)
if completion_date < due_date:
return True
if completion_date > due_date:
return False
if instance.due_time is None:
return True
if completion_time is None:
raise ValueError("now is required to compare due_time")
due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time()
return completion_time <= due_time
class _TaskReentrantLock:
"""Shared connection lock the owning task may re-enter."""
def __init__(self, inner: asyncio.Lock) -> None:
self._inner = inner
self._task: asyncio.Task | None = None
self._depth = 0
def locked(self) -> bool:
return self._inner.locked()
async def acquire(self) -> bool:
task = asyncio.current_task()
if self._task is task:
self._depth += 1
return True
await self._inner.acquire()
self._task = task
self._depth = 1
return True
def release(self) -> None:
if self._task is not asyncio.current_task():
raise RuntimeError("lock released by a non-owner")
self._depth -= 1
if self._depth == 0:
self._task = None
self._inner.release()
async def __aenter__(self) -> _TaskReentrantLock:
await self.acquire()
return self
async def __aexit__(self, *exc: object) -> None:
self.release()
def _reentrant_connection_lock(database) -> _TaskReentrantLock:
"""Return the shared connection lock, made task-reentrant.
``CompletionEventsDao.append`` also acquires this lock, so
check+append in :func:`complete_instance` and
:func:`uncomplete_instance` must re-enter on the same task. The
wrapper is stored in the shared dict so the DAO sees the same
object.
"""
loop = asyncio.get_running_loop()
key = (id(database), id(loop))
lock = _CONNECTION_LOCKS.get(key)
if isinstance(lock, _TaskReentrantLock):
return lock
wrapped = _TaskReentrantLock(
lock if lock is not None else asyncio.Lock()
)
_CONNECTION_LOCKS[key] = wrapped
return wrapped
def _validate_int_id(value: object, field: str) -> int:
"""Reject non-int ids (bools included) before any lookup."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{field} must be an integer, got {value!r}")
return value
def _validate_event_type(value: object) -> str:
"""Return a schema-legal event type, or raise naming the field."""
if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {value!r}"
)
return value
def _validate_actor(
actor_source: object,
actor_user_id: object,
actor_child_id: object,
) -> tuple[str, str | None, int | None]:
"""Return a schema-legal actor triple, or raise naming the field.
``'user'`` requires a non-empty ``actor_user_id`` string and forbids
``actor_child_id``. ``'panel'`` requires an integer
``actor_child_id`` and forbids ``actor_user_id``. Any other
``actor_source`` (including ``'service'``) is rejected.
"""
if actor_source not in ("user", "panel"):
raise ValueError(
f"actor_source must be 'user' or 'panel', got {actor_source!r}"
)
if actor_source == "user":
if not isinstance(actor_user_id, str) or not actor_user_id.strip():
raise ValueError("actor_source 'user' requires actor_user_id")
if actor_child_id is not None:
raise ValueError(
"actor_source 'user' must not carry actor_child_id"
)
return "user", actor_user_id.strip(), None
if actor_user_id is not None:
raise ValueError("actor_source 'panel' must not carry actor_user_id")
if actor_child_id is None:
raise ValueError(
"actor_source 'panel' requires actor_child_id"
)
return (
"panel",
None,
_validate_int_id(actor_child_id, "actor_child_id"),
)
def _validate_was_on_time(
event_type: str, was_on_time: object
) -> bool | None:
"""Return a real bool or None; completed events require True/False."""
if was_on_time is not None and type(was_on_time) is not bool:
raise ValueError(
f"was_on_time must be True, False or None, got {was_on_time!r}"
)
if event_type == EVENT_COMPLETED and was_on_time is None:
raise ValueError(
"a completed event requires was_on_time (True/False)"
)
return was_on_time
async def append_event(
database: NestQuestDatabase,
instance_id: int,
child_id: int,
event_type: str,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
was_on_time: bool | None = None,
now: datetime.datetime | None = None,
) -> CompletionEventRecord:
"""Append one completion or un-completion event and return the row.
Validates actor shapes and the completed-event on-time requirement
before calling :meth:`CompletionEventsDao.append`. ``occurred_at``
is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
child_id = _validate_int_id(child_id, "child_id")
event_type = _validate_event_type(event_type)
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
was_on_time = _validate_was_on_time(event_type, was_on_time)
occurred_at = _stamp_occurred_at(now)
return await CompletionEventsDao(database).append(
instance_id,
child_id,
event_type,
actor_source,
occurred_at,
was_on_time,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
)
async def instance_state(
database: NestQuestDatabase,
instance_id: int,
*,
today: datetime.date | None = None,
) -> str:
"""Return ``open``, ``done``, or ``missed`` from the latest event.
No events, or a latest event of uncompleted, is ``open`` unless
``due_date`` is before ``today`` (HA-local, threaded), in which
case it is ``missed``. A latest event of completed is ``done``
even when past due. ``missed`` is never stored as an event_type.
Unknown ``instance_id`` raises ValueError naming the field.
Existence and the latest event are read inside the connection lock
so the answer is one snapshot.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
today_date = _resolve_today(today)
async with _connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
return _derived_state(instance, latest, today_date)
class CompletionResult(str):
"""The derived instance state, carrying the append verdict.
A ``str`` subclass so every existing ``result == "done"`` contract
keeps working; ``appended`` reports whether THIS call actually
appended an event (decided under the same lock as the append), so
a caller — the Feature 09 service path firing bus events —
announces a transition exactly once even under a concurrent
duplicate call where the loser's write is a no-op. A completion
also carries the ``was_on_time`` verdict computed under that same
lock, so the event payload never re-reads mutable latest state a
racing reversal could have replaced.
"""
appended: bool
was_on_time: bool | None
def __new__(
cls,
state: str,
*,
appended: bool,
was_on_time: bool | None = None,
) -> "CompletionResult":
obj = super().__new__(cls, state)
obj.appended = appended
obj.was_on_time = was_on_time
return obj
async def complete_instance(
database: NestQuestDatabase,
instance_id: int,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> CompletionResult:
"""Append a completed event unless the instance is already done.
Returns the derived state ``done``; ``appended`` reports whether
THIS call appended (False on an already-done no-op). Unknown
``instance_id`` raises ValueError naming the field. Existence,
latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append.
``was_on_time`` is computed from the HA-local completion moment
(threaded ``now`` / ``today``) against the instance ``due_date``
and optional ``due_time``. ``now`` is the timezone-aware HA-local
datetime; its wall clock is never converted to UTC. Same-day
``due_time`` comparison requires ``now``.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
async with _reentrant_connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
if latest is not None and latest.event_type == EVENT_COMPLETED:
return CompletionResult("done", appended=False)
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
# Computed under the completion lock and carried on the result:
# the bus event must report THIS completion's timing, never a
# re-read of the latest event a racing reversal could replace.
on_time = _was_on_time(instance, now, today)
await append_event(
database,
instance.id,
instance.child_id,
EVENT_COMPLETED,
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=on_time,
now=now,
)
return CompletionResult("done", appended=True, was_on_time=on_time)
async def uncomplete_instance(
database: NestQuestDatabase,
instance_id: int,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> CompletionResult:
"""Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``, or ``missed`` when the instance
is past due (``due_date`` before HA-local ``today``); ``appended``
reports whether THIS call appended (False on an already-open
no-op). Unknown ``instance_id`` raises ValueError naming the
field. Existence, latest-event, and the append run under one
connection lock so a concurrent uncomplete cannot double-append.
The original completed row is not modified.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
today_date = _resolve_today(today)
async with _reentrant_connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
return CompletionResult(
_derived_state(instance, latest, today_date),
appended=False,
)
if latest.event_type != EVENT_COMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
latest = await append_event(
database,
instance.id,
instance.child_id,
EVENT_UNCOMPLETED,
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=None,
now=now,
)
return CompletionResult(
_derived_state(instance, latest, today_date), appended=True
)
async def list_missed_for_child(
database: NestQuestDatabase,
child_id: int,
range_start: str,
range_end: str,
*,
today: datetime.date | None = None,
) -> list[QuestInstanceRecord]:
"""Return the child's missed instances due in the closed date range.
Missed means the latest event state is open and ``due_date`` is
before ``today`` (HA-local, threaded). Uses
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
Never appends a missed event. Instances and latest events are
read inside the connection lock so the list is one snapshot.
"""
child_id = _validate_int_id(child_id, "child_id")
today_date = _resolve_today(today)
async with _connection_lock(database):
instances = await QuestInstancesDao(database).list_by_date_range(
child_id, range_start, range_end
)
events = CompletionEventsDao(database)
missed: list[QuestInstanceRecord] = []
for instance in instances:
latest = await events.get_latest_for_instance(instance.id)
if _derived_state(instance, latest, today_date) == "missed":
missed.append(instance)
return missed
+155
View File
@@ -0,0 +1,155 @@
"""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.
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_events(
database: NestQuestDatabase,
instance_id: int,
*,
was_on_time: bool | None,
today: datetime.date,
) -> list[tuple[str, dict]]:
"""Build the transition events 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 a list of ``(event_type, payload)`` tuples to fire, in
order: always ``nestquest_quest_completed`` first, and — when the
completion cleared the child's whole day —
``nestquest_child_day_complete`` after it. The caller fires them
on the bus.
``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.
"""
now_stamp = _now_stamp()
payload = await _instance_payload(database, instance_id, now_stamp)
payload["was_on_time"] = was_on_time
events: list[tuple[str, dict]] = [(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.
if payload["due_date"] != today.isoformat():
return events
instances = await QuestInstancesDao(database).list_by_date_range(
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 instances and remaining == 0:
events.append(
(
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,
},
)
)
return events
async def build_quest_uncompleted_events(
database: NestQuestDatabase, instance_id: int
) -> list[tuple[str, dict]]:
"""Build the ``nestquest_quest_uncompleted`` event for an actual reversal.
Returns a one-element list of ``(event_type, payload)`` to fire.
"""
payload = await _instance_payload(database, instance_id, _now_stamp())
return [(EVENT_QUEST_UNCOMPLETED, payload)]
@@ -0,0 +1,377 @@
"""The materialization walk (Feature 07 core).
:func:`materialize` turns the schedule rules + custody presence of the
active quest definitions into concrete ``quest_instances`` rows over a
date range. It is the pure walk over definitions x dates x assignees x
windows; HA scheduling (day-rollover triggers) is a separate later
task and lives elsewhere.
The walk is deterministic and idempotent:
- All inputs — the active definitions with their rules, assignees and
windows, AND the assignee children's presence schedules and scoped
overrides — are read inside ONE locked transaction (see
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
single coherent snapshot rather than a definition state from one
instant and a presence state from another.
- Presence is snapshotted into an immutable
:class:`~.presence.PresenceEngine` from that same coherent read.
- For each date in the range, each definition whose decoded rule
:func:`~.recurrence.occurs_on` on that date, each assignee who is
``is_present`` on that date and each declared window, one instance is
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
A config change that lands AFTER the snapshot is read but BEFORE a
tuple's write (an assignment removed, a definition or child deactivated,
a window removed) makes that tuple SKIP rather than abort the batch:
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
every materialization precondition (active definition, active child,
live assignment, declared window, and its CURRENT ``due_time``) inside
the SAME transaction as the INSERT, so the check and the write cannot be
split by a racing config edit.
The upsert is idempotent on (definition_id, child_id, due_date,
window), so re-running the materialization over the same range never
duplicates a row; a tuple whose instance is already completed is SKIPPED
(never rewritten, never raised) by
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
Regeneration: a config or presence change must rebuild future instances
so they track the new state. :func:`regenerate_for_definition` covers
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
covers a single child's presence change (Feature 09). Both delete the
affected open instances at or after today — never a completed instance,
never the past — and re-run the walk over the rolling horizon
``[today, today + horizon_days]`` (``horizon_days`` defaults to
``DEFAULT_HORIZON_DAYS``).
HOOK (Feature 09 presence services): the presence business layer that
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
presence-state write so a child's future instances track its presence:
``set_presence_pattern`` (including clearing/setting an empty pattern),
deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
``create_presence_override``, and ``delete_presence_override``. A
schedule delete changes presence state too (the child becomes
always-present), so it must regenerate just like the others. This hook
is documented, not yet wired: no presence business layer exists yet, and
the presence DAO (``dao_presence.py``) must stay free of business rules
— never call :func:`regenerate_for_child` from inside the DAO layer.
"""
from __future__ import annotations
import datetime
from .const import DEFAULT_HORIZON_DAYS
from .dao_instances import QuestInstancesDao, _resolve_today
from .dao_rules import (
_validate_date,
load_materialization_input,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
)
from .db import NestQuestDatabase
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
from .recurrence import ScheduleRule, occurs_on
def _now_stamp() -> str:
"""Return the batch's shared UTC second-precision generation stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _resolve_horizon_days(horizon_days: int | None) -> int:
"""Validate a regeneration horizon and return the resolved day count.
``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool
(SQLite would happily bind ``True``/``False`` onto an int), a non-int,
or a value below 1 raises ValueError naming the field — the validation
runs BEFORE any future-instance delete so a malformed horizon can never
delete rows it then fails to rebuild. A negative horizon would
otherwise delete first and only then be rejected by
:func:`materialize`'s inverted-range check; ``0``/``False`` would
silently shrink the window to a single day.
"""
if horizon_days is None:
return DEFAULT_HORIZON_DAYS
if (
isinstance(horizon_days, bool)
or not isinstance(horizon_days, int)
or horizon_days < 1
):
raise ValueError(
f"horizon_days must be a positive integer, got {horizon_days!r}"
)
return horizon_days
def _decode_rule(snapshot) -> ScheduleRule:
"""Decode a snapshot's rule record back into a ScheduleRule.
The record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through ``ScheduleRulesDao``) preserves the coherence the snapshot
guarantees.
"""
return schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
def _build_engine(
schedules_records,
overrides_records,
) -> PresenceEngine:
"""Convert raw presence records into an immutable PresenceEngine."""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
child_id, record.anchor_date, record.pattern
)
overrides: dict[int, list[PresenceOverride]] = {}
for child_id, records in overrides_records.items():
overrides[child_id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in records
]
return PresenceEngine(schedules, overrides)
async def materialize(
database: NestQuestDatabase,
start_date: str,
end_date: str,
*,
child_ids: list[int] | None = None,
today: datetime.date | None = None,
) -> int:
"""Generate quest instances for the closed range [start_date, end_date].
``start_date`` and ``end_date`` must be strict ISO calendar dates
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
else raises ValueError naming the offending field. Returns the number
of instances upserted for the range (idempotent — a re-run refreshes
the same rows, never duplicates them).
``child_ids`` optionally scopes the walk to exactly those children:
assignees outside the list are never written, so a caller that only
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
after a presence change) cannot touch any unrelated child's rows.
When omitted (or None) the walk covers every assignee as before.
``today`` optionally pins the resolved HA-local date the walk's
no-past guard compares against; it is threaded into every
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
omitted the host clock (``datetime.date.today``) is used, which is the
historical default. A caller that computed the horizon in Home
Assistant's configured time zone MUST pass that same date here so a
time zone behind the host around midnight is not rejected as "past".
``start_date`` is clamped up to the resolved "today" (the same
``today`` anchor threaded into the no-past guard), so a caller that
passes a ``start_date`` already in the past never yields a
past-dated instance: the walk simply begins on today and skips every
earlier date. A range entirely in the past therefore produces zero
instances rather than an error, and the DAO's no-past guard remains
the authoritative backstop under the lock.
All inputs are read in ONE locked transaction; each tuple's write then
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
which re-validates the tuple and its current ``due_time`` in the same
transaction as the INSERT, so a config change that arrives mid-batch
skips the affected tuples rather than aborting the batch or leaving a
partial result.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
if end_date < start_date:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end_date!r} < {start_date!r}"
)
snapshots, schedules_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
engine = _build_engine(schedules_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids)
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows)
for snapshot in snapshots
]
generated_at = _now_stamp()
instances = QuestInstancesDao(database)
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
# Clamp a past start up to today (the HA-local anchor when the caller
# pinned one, else the host clock) so the walk can never emit a
# past-dated instance; the DAO's no-past guard remains the backstop.
# A range entirely in the past therefore yields zero instances.
today_date = _resolve_today(today)
start = max(start, today_date)
count = 0
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for definition_id, rule, assignees, windows in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if not engine.is_present(child.id, cursor):
continue
for window in windows:
written = await instances.upsert_if_valid(
definition_id,
child.id,
iso,
generated_at,
window=window.window,
due_time=window.due_time,
today=today,
)
if written is not None:
count += 1
cursor += datetime.timedelta(days=1)
return count
async def regenerate_for_definition(
database: NestQuestDatabase,
definition_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a definition's future instances after a config change.
The Feature 06 edit/assignment/activation paths call this after a
successful write so future instances track the new state: the
definition's open instances at or after today are deleted (never a
completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
then the materialization walk re-runs over the rolling horizon
``[today, today + horizon_days]`` so the (changed) rule,
assignee set and windows re-materialize against today's state.
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — so the delete cutoff and the
re-materialization range share one anchor, and the walk's
``upsert_if_valid`` no-past guard never rejects the regenerated
range. ``today`` optionally pins a caller-resolved HA-local date
(threaded into both the delete cutoff and the re-materialization)
so a household time zone behind the host around midnight stays
consistent. ``horizon_days`` sizes the re-materialization window;
when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`.
A bool, non-int, or sub-1 value raises ValueError BEFORE any delete.
Returns the number of instances the re-materialization
upserted across the whole snapshot (idempotent — other active
definitions' tuples refresh in place, never duplicate).
"""
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
start_date = today_date.isoformat()
end_date = (
today_date + datetime.timedelta(days=horizon)
).isoformat()
instances = QuestInstancesDao(database)
await instances.delete_future_uncompleted(
definition_id, start_date, today=today
)
return await materialize(database, start_date, end_date, today=today)
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
# child-presence counterpart to ``regenerate_for_definition``. The
# presence business layer that lands in Feature 09 MUST call
# ``regenerate_for_child`` after EVERY presence-state write, so future
# instances track a child's changed presence: set_presence_pattern
# (including clearing/setting an empty pattern), deleting/clearing the
# presence schedule (PresenceSchedulesDao.delete),
# create_presence_override, and delete_presence_override. A schedule
# delete changes presence state too (the child becomes always-present),
# so it must regenerate just like the others. It is deliberately NOT
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
# business rules), and no presence business layer exists yet to host the
# call — see the module docstring note above.
async def regenerate_for_child(
database: NestQuestDatabase,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a child's future instances after a presence change.
The Feature 09 presence services call this after every
presence-state write — set_presence_pattern (including clearing or
setting an empty pattern), deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
create_presence_override, and delete_presence_override — so a
child's future instances track its new presence: the child's open
instances at or after today are deleted across ALL its definitions
(never a completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
then the materialization walk re-runs over the rolling horizon
``[today, today + horizon_days]`` scoped to that child only,
so the child's now-absent/present dates re-materialize against
today's presence WITHOUT touching any other child's rows.
``child_id`` must be a plain int — bool and float are rejected
(SQLite binds a bool as 0/1 and a float would round, so a malformed
id must never mutate the wrong profile).
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — and the re-materialization range uses the
delete's returned effective cutoff (see
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
so the delete cutoff and the re-materialization window share ONE
execution-day anchor even when the call is queued across midnight.
``today`` optionally pins a caller-resolved HA-local date, threaded
into the delete cutoff and the re-materialization for the same
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
re-materialization window; when omitted it falls back to
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
raises ValueError BEFORE any delete. Returns the number of instances
the re-materialization upserted for the child across the whole
snapshot.
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
instances = QuestInstancesDao(database)
# Use the delete's returned effective cutoff as the materialize anchor,
# NOT the independently-computed "today" above. If the call is queued
# across midnight the delete clamps its cutoff to the execution-day;
# materializing from the stale "today" (now yesterday) would trip the
# walk's no-past guard and leave the child's future instances deleted
# but not rebuilt. One anchor for both keeps them consistent.
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
child_id, today_date.isoformat(), today=today
)
start = datetime.date.fromisoformat(effective_cutoff)
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
return await materialize(
database, start.isoformat(), end_date, child_ids=[child_id], today=today
)
@@ -0,0 +1,477 @@
"""The presence and custody model (Feature 05).
PURE module: no Home Assistant imports, no database access, no
timezone logic — it operates on plain calendar dates only (feature
guardrail). The later engine (cycle position, ``is_present``,
overrides, previews) builds directly on this model.
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
week is this child at this house?" for an N-week repeating custody
pattern. Two of the household's three children follow a two-week
rotation; a child with NO schedule row is present every day (that
default lives in the engine, not here).
Pattern encoding: the model's canonical serialization is the SAME
pipe-separated CSV the database stores (see schema.py's
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
empty segment means absent every day of that week. Encoding sorts
each segment's weekdays and uses the exact segment order, so
``decode(encode(schedule))`` round-trips losslessly and two schedules
with equal patterns produce equal strings.
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
computed from anchor-date arithmetic ONLY — never ISO week numbers or
week parity (D-004): 53-week years silently invert parity on January
1st. The arithmetic lives in the engine task; this module validates
that the anchor is a strict calendar date.
Validation policy: construction is total — every reject happens in
``__init__`` so no invalid schedule can ever exist:
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
the database CHECK (schema.py) and keeps anchor arithmetic and the
encoded pattern human-readable; revisit both together if a longer
cycle is ever needed.
- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly
(no gaps, no extra weeks) with sets of integers 0..6 (bools
rejected: True would silently mean Monday).
- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a
``datetime.date``).
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from types import MappingProxyType
#: The schema's documented cap for cycle_length_weeks (schema.py keeps
#: the pattern human-readable and the anchor arithmetic small).
MAX_CYCLE_LENGTH_WEEKS = 4
_DATE_FORMAT = "%Y-%m-%d"
def _parse_date(value: object, field_name: str) -> datetime.date:
"""Accept a datetime.date or a strict YYYY-MM-DD string.
``datetime.datetime`` is rejected explicitly: it SUBCLASSES
datetime.date, so a bare isinstance check would store a value with
a time component and every later calendar-date comparison (e.g.
``PresenceOverride.covers``) would raise TypeError.
"""
if isinstance(value, datetime.datetime):
raise ValueError(
f"{field_name} must be a plain calendar date, not a "
f"datetime with a time component: {value!r}"
)
if isinstance(value, datetime.date):
return value
if not isinstance(value, str):
raise ValueError(
f"{field_name} must be a datetime.date or an ISO date string, "
f"got {value!r}"
)
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise ValueError(
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise ValueError(
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
)
return parsed
def _validate_week_index(week: object, cycle_length_weeks: int) -> int:
"""Reject non-int week indices (bools included) and out-of-cycle."""
if isinstance(week, bool) or not isinstance(week, int):
raise ValueError(
f"pattern week index must be an integer, got {week!r}"
)
if not 0 <= week < cycle_length_weeks:
raise ValueError(
f"pattern week index must be in 0..{cycle_length_weeks - 1}, "
f"got {week}"
)
return week
def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
"""Validate one week's present-weekday set."""
if isinstance(weekdays, (str, bytes)) or weekdays is None:
raise ValueError(
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
f"got {weekdays!r}"
)
try:
members = list(weekdays)
except TypeError:
raise ValueError(
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
f"got {weekdays!r}"
) from None
for member in members:
if isinstance(member, bool) or not isinstance(member, int):
raise ValueError(
f"pattern[{week}] weekday must be an int 0..6, got {member!r}"
)
if not 0 <= member <= 6:
raise ValueError(
f"pattern[{week}] weekday must be in 0..6 (Monday=0), "
f"got {member}"
)
return frozenset(members)
def _validate_cycle_length(value: object) -> int:
"""Reject non-int and out-of-range cycle lengths (bools included)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(
f"cycle_length_weeks must be an integer, got {value!r}"
)
if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS:
raise ValueError(
f"cycle_length_weeks must be between 1 and "
f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}"
)
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools would silently address child 1)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
@dataclass(frozen=True, init=False)
class PresenceSchedule:
"""One child's N-week repeating presence pattern.
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
to the set of present weekdays (ints, Monday=0). A week with an
empty set means the child is absent every day of that week — which
is distinct from having no schedule at all (the engine treats a
missing schedule as present every day).
"""
child_id: int
cycle_length_weeks: int
anchor_date: datetime.date
pattern: MappingProxyType # week index -> frozenset of weekdays
def __init__(
self,
child_id: int,
cycle_length_weeks: int,
anchor_date: object,
pattern: dict[int, object],
) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id))
cycle = _validate_cycle_length(cycle_length_weeks)
object.__setattr__(self, "cycle_length_weeks", cycle)
object.__setattr__(
self, "anchor_date", _parse_date(anchor_date, "anchor_date")
)
if not isinstance(pattern, dict):
raise ValueError(
"pattern must map week index to a set of present "
f"weekdays, got {pattern!r}"
)
normalised: dict[int, frozenset[int]] = {}
for week, weekdays in pattern.items():
index = _validate_week_index(week, cycle)
normalised[index] = _validate_weekday_set(index, weekdays)
missing = [
week for week in range(cycle) if week not in normalised
]
if missing:
raise ValueError(
f"pattern must cover week indices 0..{cycle - 1}; "
f"missing {missing}"
)
# Frozen must mean frozen: a bare dict would let callers inject
# unvalidated weekdays (or drop required weeks) AFTER
# construction, bypassing total validation. The mapping
# proxy exposes the validated contents read-only; the
# per-week sets are already frozensets.
object.__setattr__(self, "pattern", MappingProxyType(normalised))
def encode(self) -> str:
"""Serialize to the database's pipe-separated CSV shape.
Segments appear in week order; each segment is the week's
present weekdays as a sorted comma-separated list (an empty set
encodes as an empty segment). Two schedules with equal
patterns encode identically.
"""
segments = [
",".join(str(day) for day in sorted(self.pattern[week]))
for week in range(self.cycle_length_weeks)
]
return "|".join(segments)
@classmethod
def decode(
cls,
child_id: int,
anchor_date: object,
encoded: str,
) -> "PresenceSchedule":
"""Build a schedule from its encoded pattern.
The cycle length is derived from the segment count, mirroring
the database's segment-count CHECK. Decoding is strict: wrong
shapes, non-numeric or out-of-range weekdays are rejected —
the database CHECKs make such rows unrepresentable, and this
is the same contract on the model side. A segment count above
the documented cycle cap is rejected by the ordinary
construction validation.
"""
if not isinstance(encoded, str):
raise ValueError(
f"encoded pattern must be a string, got {encoded!r}"
)
segments = encoded.split("|")
pattern: dict[int, set[int]] = {}
for week, segment in enumerate(segments):
weekdays: set[int] = set()
if segment:
for part in segment.split(","):
if len(part) != 1 or part not in "0123456":
raise ValueError(
f"encoded segment {week} carries an invalid "
f"weekday {part!r} (single digits 0-6 only)"
)
weekdays.add(int(part))
pattern[week] = weekdays
return cls(child_id, len(segments), anchor_date, pattern)
@dataclass(frozen=True, init=False)
class PresenceOverride:
"""A date-specific present/absent flag beating any pattern.
Covers a single date (``end_date == start_date``) or an inclusive
range. A concrete range is the storage shape
(``presence_overrides.end_date`` is NOT NULL), so the model
requires both bounds and validates start <= end. Construction and
validation live here so the engine can trust every instance.
"""
child_id: int
start_date: datetime.date
end_date: datetime.date
is_present: bool
note: str | None
def __init__(
self,
child_id: int,
start_date: object,
end_date: object,
is_present: bool,
note: str | None = None,
) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id))
start = _parse_date(start_date, "start_date")
end = _parse_date(end_date, "end_date")
if end < start:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end.isoformat()} < {start.isoformat()}"
)
object.__setattr__(self, "start_date", start)
object.__setattr__(self, "end_date", end)
if not isinstance(is_present, bool):
raise ValueError(
f"is_present must be a real bool, got {is_present!r}"
)
object.__setattr__(self, "is_present", is_present)
if note is not None and not isinstance(note, str):
raise ValueError(f"note must be a string or None, got {note!r}")
object.__setattr__(self, "note", note)
def covers(self, date: datetime.date) -> bool:
"""Return True when the override applies to ``date``."""
return self.start_date <= date <= self.end_date
def cycle_week_index(
schedule: PresenceSchedule, target_date: object
) -> int:
"""Return the cycle week index ``target_date`` falls in.
Anchor-date arithmetic ONLY (D-004): ``((target_date -
anchor_date).days // 7) % cycle_length_weeks``. Python's floor
division makes dates BEFORE the anchor behave correctly — they
walk the cycle backwards (day -1 is the last week of the cycle,
day -8 wraps one further) instead of inverting, which is exactly
what ISO week parity does wrong in 53-week years. The modulo of
a negative in Python is non-negative, so the index is always a
valid week index.
"""
target = _parse_date(target_date, "target_date")
days = (target - schedule.anchor_date).days
return (days // 7) % schedule.cycle_length_weeks
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
#: years of daily checks. A child absent every day (an all-absent
#: pattern with a blocking override) would otherwise loop forever; the
#: cap bounds the scan and surfaces the situation as an error instead.
MAX_PREVIEW_SCAN_DAYS = 366 * 2
class PresenceEngine:
"""Answers "is this child at this house on this date?".
Pure and side-effect-free: built from an immutable snapshot of the
children's schedules (``child_id -> PresenceSchedule``), it never
touches the database — callers build a snapshot from the DAO layer
and pass it in. A child with NO schedule entry is present every
day (the third household child's case); a child WITH a schedule is
present exactly when the date's weekday is in that schedule's
pattern for the date's cycle week index (anchor-date arithmetic —
:func:`cycle_week_index`, never ISO week parity).
"""
def __init__(
self,
schedules: dict[int, PresenceSchedule],
overrides: dict[int, list[PresenceOverride]] | None = None,
) -> None:
if not isinstance(schedules, dict):
raise ValueError(
f"schedules must map child_id to PresenceSchedule, got "
f"{schedules!r}"
)
for child_id, schedule in schedules.items():
# Validate the KEY as a model child id too: True and 1.0
# compare equal to 1, so {True: schedule(1)} would be
# accepted and then behave differently on lookup.
_validate_child_id(child_id)
if not isinstance(schedule, PresenceSchedule):
raise ValueError(
f"schedules[{child_id!r}] must be a PresenceSchedule, "
f"got {schedule!r}"
)
if schedule.child_id != child_id:
raise ValueError(
f"schedules is keyed {child_id!r} but holds the "
f"schedule of child {schedule.child_id}"
)
if overrides is None:
overrides = {}
if not isinstance(overrides, dict):
raise ValueError(
"overrides must map child_id to a list of "
f"PresenceOverride, got {overrides!r}"
)
for child_id, entries in overrides.items():
_validate_child_id(child_id)
if not isinstance(entries, list):
raise ValueError(
f"overrides[{child_id!r}] must be a list of "
f"PresenceOverride, got {entries!r}"
)
for entry in entries:
if not isinstance(entry, PresenceOverride):
raise ValueError(
f"overrides[{child_id!r}] entries must be "
f"PresenceOverride, got {entry!r}"
)
if entry.child_id != child_id:
raise ValueError(
f"overrides is keyed {child_id!r} but holds an "
f"override of child {entry.child_id}"
)
# Read-only snapshot: reassignment is refused and no mutator
# exists, so a built engine answers consistently for its
# lifetime. The overrides lists are copied to immutable
# tuples — a shallow mapping copy alone would leave caller-
# owned lists reachable through _overrides, and mutating one
# later would change is_present results.
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
object.__setattr__(
self,
"_overrides",
MappingProxyType(
{child_id: tuple(entries) for child_id, entries in overrides.items()}
),
)
def is_present(self, child_id: object, date: object) -> bool:
"""Return whether ``child_id`` is present on ``date``.
Overrides take precedence over the repeating pattern: when an
override covers the date, its flag IS the answer — including
for a child with no schedule at all. The DAO layer guarantees
a child's overrides never overlap; if a snapshot still carries
two covering the same date, the one with the LATEST start date
wins (deterministic, and matches "the most specific swap wins").
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
target = _parse_date(date, "date")
entries = self._overrides.get(child_id) or []
covering = [
entry for entry in entries if entry.covers(target)
]
if covering:
winner = max(covering, key=lambda entry: entry.start_date)
return winner.is_present
schedule = self._schedules.get(child_id)
if schedule is None:
# No schedule at all: present every day (household default).
return True
week = cycle_week_index(schedule, target)
return target.weekday() in schedule.pattern[week]
def next_present_dates(
self,
child_id: object,
start_date: object,
count: int,
) -> list[datetime.date]:
"""Return the next ``count`` dates the child is present.
Walks forward from ``start_date`` (INCLUSIVE — a parent
previewing "starting Monday" expects Monday itself when the
child is present) checking :meth:`is_present` for every day,
so overrides and the pattern are honoured identically. The
admin Schedule tab renders this preview so a parent can confirm
a custody pattern is right before saving it.
A child absent every scanned day raises ValueError after the
two-year search cap — an always-absent child would otherwise
loop forever. ``count`` must be a positive integer.
"""
_validate_child_id(child_id)
start = _parse_date(start_date, "start_date")
if isinstance(count, bool) or not isinstance(count, int):
raise ValueError(f"count must be an integer, got {count!r}")
if count < 1:
raise ValueError(f"count must be at least 1, got {count}")
found: list[datetime.date] = []
day = start
for _ in range(MAX_PREVIEW_SCAN_DAYS):
if self.is_present(child_id, day):
found.append(day)
if len(found) == count:
return found
day += datetime.timedelta(days=1)
raise ValueError(
f"child {child_id} is not present on any of the "
f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; "
f"{count} present day(s) cannot be previewed"
)
def __setattr__(self, name: object, value: object) -> None:
raise AttributeError(
"PresenceEngine is an immutable snapshot; build a new engine "
"instead of mutating this one"
)
@@ -0,0 +1,603 @@
"""Business layer for creating and editing quest definitions (Feature 06).
Sits between the Feature 09 service gate and the typed
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path,
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
Validation policy mirrors :mod:`.children`:
- ``title`` is required and trimmed; empty or whitespace-only raises
ValueError.
- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
(its constructor is total — rule validation is NOT re-implemented
here).
- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``);
every id is a plain int (bools and floats rejected, since SQLite would
bind them onto a real child) and must reference an existing ACTIVE
child (the DAO enforces the active/exists check inside the same
transaction). Duplicate ids are rejected here, before the composite
primary key could surface them as an :class:`sqlite3.IntegrityError`.
- ``windows`` must be a non-empty list (strictly a ``list``); each entry
is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare
name (no due time) or a ``(name, due_time)`` pair whose due time is a
strict 24-hour HH:MM string. Duplicate window names are rejected
here, before the composite primary key could surface them as an
:class:`sqlite3.IntegrityError`.
The rule, definition, assignees and windows are persisted in ONE
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
so a rejected or failed create persists nothing. The definition-level
``due_time`` column is superseded by per-window due times (D-008) and is
never set here. ``created_at`` is stamped UTC ISO-8601 at second
precision, matching :mod:`.children`.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import ChildRecord, _connection_lock
from .dao_rules import (
QuestDefinitionRecord,
QuestDefinitionSnapshot,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
ScheduleRuleStorage,
ScheduleRulesDao,
_UNSET,
_validate_date,
_validate_time,
_validate_window,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
from .materialize import regenerate_for_definition
from .recurrence import ScheduleRule, occurs_on
@dataclass(frozen=True)
class CreatedQuestDefinition:
"""The persisted result of a create, edit or activation change.
``definition`` is the stored row; ``rule`` is the schedule rule
decoded back through the storage mapping; ``assignees`` and
``windows`` are the definition's linked child records and window
declarations, read through the DAO.
"""
definition: QuestDefinitionRecord
rule: ScheduleRule
assignees: list[ChildRecord]
windows: list[QuestDefinitionWindowRecord]
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_text(
value: object, field: str, *, required: bool = False
) -> str | None:
"""Validate a free-text field and return it trimmed.
``None`` raises when ``required`` and otherwise returns None. A
non-string value is rejected; an empty result is rejected for
required fields.
"""
if value is None:
if required:
raise ValueError(f"{field} is required")
return None
if not isinstance(value, str):
raise ValueError(f"{field} must be a string, got {value!r}")
trimmed = value.strip()
if required and not trimmed:
raise ValueError(f"{field} is required")
return trimmed
def _validate_assignee_ids(value: object) -> list[int]:
"""Return the assignee ids as plain ints, or raise naming the field.
The argument must be a non-empty list — strictly a ``list``: tuples
and other sequences are rejected. Each entry must be a real int
(bools and floats rejected) so SQLite cannot bind ``True`` onto
child 1 or a float onto a neighbouring profile.
"""
if type(value) is not list:
raise ValueError(
f"assignee_child_ids must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("assignee_child_ids must not be empty")
ids: list[int] = []
for entry in value:
if isinstance(entry, bool) or not isinstance(entry, int):
raise ValueError(
"assignee_child_ids must contain integer child ids, "
f"got {entry!r}"
)
ids.append(entry)
if len(set(ids)) != len(ids):
raise ValueError(
"assignee_child_ids must not contain duplicate child ids"
)
return ids
def _normalize_windows(
value: object,
) -> list[tuple[str, str | None]]:
"""Normalize the windows argument to ``(name, due_time)`` pairs.
The outer argument must be a non-empty list — strictly a ``list``:
tuples and other sequences are rejected, while each individual entry
may still be a ``(window, due_time)`` tuple. A bare window name
(due time None) is also accepted. The name must be a
:data:`~.const.QUEST_WINDOWS` spelling and a present due time must be
a strict 24-hour HH:MM, both validated up front so a malformed
window is rejected before any write.
"""
if type(value) is not list:
raise ValueError(
f"windows must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("windows must not be empty")
normalized: list[tuple[str, str | None]] = []
seen: set[str] = set()
for index, entry in enumerate(value):
if isinstance(entry, str):
name: object = entry
due_time: object = None
elif isinstance(entry, (tuple, list)) and len(entry) == 2:
name, due_time = entry[0], entry[1]
else:
raise ValueError(
f"windows[{index}] must be a window name or a "
f"(name, due_time) pair, got {entry!r}"
)
_validate_window(name)
if due_time is not None:
_validate_time(due_time, "due_time")
if name in seen:
raise ValueError(
f"windows must not contain duplicate window {name!r}"
)
seen.add(name)
normalized.append((name, due_time))
return normalized
def _validate_definition_id(value: object) -> int:
"""Reject non-int definition ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address definition 1 and a float would round — malformed service
input must never mutate the wrong definition.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"definition_id must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def create_quest_definition(
database: NestQuestDatabase,
title: str,
rule: ScheduleRule,
assignee_child_ids: list[int],
windows: list[str | tuple[str, str | None]],
*,
description: str | None = None,
icon: str | None = None,
) -> CreatedQuestDefinition:
"""Create a quest definition (rule + assignees + windows) atomically.
Validates the arguments up front, then persists the schedule rule,
the definition, its assignees and its windows in one transaction via
the DAO. Returns the stored definition together with its decoded
rule, assignees and windows — all read back inside that same
transaction, so the returned bundle is a consistent creation
snapshot. Raises ValueError on any rejected argument; a rejected or
failed create persists nothing.
"""
name = _validate_text(title, "title", required=True)
assert name is not None
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
assignee_ids = _validate_assignee_ids(assignee_child_ids)
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
storage = schedule_rule_to_storage(rule)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.create_with_rule_and_windows(
name,
storage,
_now_stamp(),
assignee_ids,
window_specs,
description=description_value,
icon=icon_value,
)
except ValueError as error:
# The DAO's assignee check names only the child; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(
f"assignee_child_ids: {message}"
) from error
raise
decoded_rule = schedule_rule_from_storage(storage)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def edit_quest_definition(
database: NestQuestDatabase,
definition_id: int,
*,
title: str | object = _UNSET,
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule and windows atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
(required and trimmed); ``description`` and ``icon`` are optional
and may be cleared by passing ``None`` explicitly. A provided
``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
and REPLACES the definition's schedule rule IN PLACE — the existing
``schedule_rules`` row is updated, never orphaned or duplicated. A
provided ``windows`` list REPLACES the whole window set (each entry
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
so the returned bundle is a consistent edit snapshot. Edits change
future instances only; no ``quest_instances`` or
``completion_events`` row is touched.
``today`` optionally pins the caller-resolved HA-local date the
regeneration's no-past guard and horizon are anchored to (threaded
into :func:`~.materialize.regenerate_for_definition`); it is a plain
``datetime.date`` with no HA import here — the Feature 09 service
supplies the hass-derived value. When omitted, regeneration falls
back to the host clock, same as before. ``horizon_days`` sizes the
regeneration's re-materialization window the same way (threaded into
:func:`~.materialize.regenerate_for_definition`); it defaults to
:data:`~.const.DEFAULT_HORIZON_DAYS` when omitted.
"""
_validate_definition_id(definition_id)
title_value: str | None | object = _UNSET
if title is not _UNSET:
name = _validate_text(title, "title", required=True)
assert name is not None
title_value = name
description_value: str | None | object = _UNSET
if description is not _UNSET:
description_value = _validate_text(description, "description")
icon_value: str | None | object = _UNSET
if icon is not _UNSET:
icon_value = _validate_text(icon, "icon")
rule_storage: ScheduleRuleStorage | object = _UNSET
if rule is not _UNSET:
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
rule_storage = schedule_rule_to_storage(rule)
window_specs: list[tuple[str, str | None]] | object = _UNSET
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.edit_definition(
definition_id,
title=title_value,
description=description_value,
icon=icon_value,
rule=rule_storage,
windows=window_specs,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def assign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> list[ChildRecord]:
"""Assign ``child_id`` to the definition (idempotent).
Validates that both ids are plain ints (bools and floats rejected)
and that the child exists and is active — the DAO enforces the
active/exists check inside its transaction, and also confirms the
definition exists. Assigning an already-assigned child is a no-op
(no duplicate row). Returns the definition's assignees read back
inside the DAO's same transaction, a consistent post-assignment
roster (a concurrent assign/unassign cannot race it after the
write). Raises ValueError on any rejected argument. Only the
``quest_definition_assignees`` link is written; existing instances
and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the assignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
try:
assignees = await dao.add_assignee(definition_id, child_id)
except ValueError as error:
# The DAO names only the child/definition; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(f"child_id: {message}") from error
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return assignees
async def unassign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> bool:
"""Unassign ``child_id`` from the definition; True when removed.
Validates both ids are plain ints (bools and floats rejected) and
that the definition exists, then removes the (definition, child)
link. Returns True when a row was removed and False when the child
was not currently assigned. Raises ValueError on a rejected
argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the unassignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
definition = await dao.get(definition_id)
if definition is None:
raise ValueError(
f"definition_id: quest definition {definition_id} does not exist"
)
removed = await dao.remove_assignee(definition_id, child_id)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return removed
async def set_quest_definition_active(
database: NestQuestDatabase,
definition_id: int,
is_active: bool,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Deactivate or reactivate a definition; returns the updated view.
Flipping the flag is the only removal path (Feature 06 guardrail):
a deactivated definition stops future instance generation but its
row, schedule rule, assignees and windows all survive, and existing
instances plus completion history are never touched. Reactivation
re-enables future generation; generating or removing instances is
Feature 07 and does not happen here.
``definition_id`` must be a plain int (bools and floats rejected,
since SQLite would bind ``True`` onto definition 1) and
``is_active`` must be a real bool — the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError naming the
offending field when the id is malformed, ``is_active`` is not a
bool, or the definition does not exist.
Atomicity: the existence check, the UPDATE and the readback of the
definition, its rule, assignees and windows all run inside the
connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race.
``today`` optionally pins the caller-resolved HA-local date threaded
into the activation change's regeneration (see
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
regeneration's re-materialization window the same way.
"""
_validate_definition_id(definition_id)
if not isinstance(is_active, bool):
raise ValueError(f"is_active must be a real bool, got {is_active!r}")
async with _connection_lock(database):
dao = QuestDefinitionsDao(database)
if await dao.get(definition_id) is None:
raise ValueError(
f"definition_id: quest definition {definition_id} "
"does not exist"
)
await dao.set_active(definition_id, is_active)
updated = await dao.get(definition_id)
assert updated is not None
rule_record = await ScheduleRulesDao(database).get(
updated.schedule_rule_id
)
assert rule_record is not None
assignees = await dao.list_assignees(definition_id)
windows = await dao.list_windows(definition_id)
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(rule_record)
)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return CreatedQuestDefinition(
definition=updated,
rule=rule,
assignees=assignees,
windows=windows,
)
def _bundle_snapshot(
snapshot: QuestDefinitionSnapshot,
) -> CreatedQuestDefinition:
"""Decode a DAO snapshot into the layer's rich view.
The rule record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
coherence the DAO's locked snapshot guarantees: the rule, assignees
and windows all come from one atomic read, never three separate ones.
"""
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def list_active_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return every ACTIVE definition with its decoded rule and links.
Ordered by rising definition id, matching the DAO's ``list_active``
order, so callers get a stable, repeatable sequence. Each entry is
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
the layer: the stored row plus its decoded
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
definitions and their links are read by the DAO inside one locked
transaction, so each bundle is a coherent snapshot that a concurrent
edit cannot interleave.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
) -> list[CreatedQuestDefinition]:
"""Return the definitions assigned to ``child_id`` (rich view).
``child_id`` must be a plain int (bools and floats rejected, since
SQLite would bind ``True`` onto child 1). Results are ordered by
rising definition id, matching ``list_active_definitions`` and
``list_definitions_firing_on``. Each entry is a
:class:`CreatedQuestDefinition` snapshot bundling the definition with
its decoded rule, assignees and windows, read atomically by the DAO.
"""
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_by_child(child_id)
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return sorted(bundles, key=lambda bundle: bundle.definition.id)
async def list_definitions_firing_on(
database: NestQuestDatabase,
date: str,
) -> list[CreatedQuestDefinition]:
"""Return the ACTIVE definitions whose rule fires on ``date``.
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
value raises ValueError naming the field. Each active definition's
stored rule is decoded through the task-1 storage mapping and handed
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
definitions whose decoded rule fires on the date are returned, in
rising definition-id order, each as the same rich
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
The definitions and their rules are read inside one locked
transaction, so a bundle can never filter on one rule while
presenting another state's windows.
"""
_validate_date(date, "date")
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
@@ -0,0 +1,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)}"
)
+31 -100
View File
@@ -1,73 +1,27 @@
"""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.
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 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.
"""
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_quest_completed_events,
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 +34,28 @@ 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_events`:
the HA-local ``today`` (the day-complete rule's input) is read
here from ``hass.config.time_zone``; the core builder does every
payload-shape and day-complete decision and hands back the events
to fire. Identical payloads 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()
events = await build_quest_completed_events(
database,
instance_id,
was_on_time=was_on_time,
today=today,
)
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,
},
)
for event_type, payload in events:
hass.bus.async_fire(event_type, payload)
async def fire_quest_uncompleted(
hass, database: NestQuestDatabase, instance_id: int
) -> None:
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
payload = await _instance_payload(database, instance_id, _now_stamp())
hass.bus.async_fire(EVENT_QUEST_UNCOMPLETED, payload)
events = await build_quest_uncompleted_events(database, instance_id)
for event_type, payload in events:
hass.bus.async_fire(event_type, payload)
+7 -373
View File
@@ -1,377 +1,11 @@
"""The materialization walk (Feature 07 core).
"""Re-export of :mod:`custom_components.nestquest.core.materialize` for the integration package.
:func:`materialize` turns the schedule rules + custody presence of the
active quest definitions into concrete ``quest_instances`` rows over a
date range. It is the pure walk over definitions x dates x assignees x
windows; HA scheduling (day-rollover triggers) is a separate later
task and lives elsewhere.
The walk is deterministic and idempotent:
- All inputs — the active definitions with their rules, assignees and
windows, AND the assignee children's presence schedules and scoped
overrides — are read inside ONE locked transaction (see
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
single coherent snapshot rather than a definition state from one
instant and a presence state from another.
- Presence is snapshotted into an immutable
:class:`~.presence.PresenceEngine` from that same coherent read.
- For each date in the range, each definition whose decoded rule
:func:`~.recurrence.occurs_on` on that date, each assignee who is
``is_present`` on that date and each declared window, one instance is
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
A config change that lands AFTER the snapshot is read but BEFORE a
tuple's write (an assignment removed, a definition or child deactivated,
a window removed) makes that tuple SKIP rather than abort the batch:
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
every materialization precondition (active definition, active child,
live assignment, declared window, and its CURRENT ``due_time``) inside
the SAME transaction as the INSERT, so the check and the write cannot be
split by a racing config edit.
The upsert is idempotent on (definition_id, child_id, due_date,
window), so re-running the materialization over the same range never
duplicates a row; a tuple whose instance is already completed is SKIPPED
(never rewritten, never raised) by
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
Regeneration: a config or presence change must rebuild future instances
so they track the new state. :func:`regenerate_for_definition` covers
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
covers a single child's presence change (Feature 09). Both delete the
affected open instances at or after today — never a completed instance,
never the past — and re-run the walk over the rolling horizon
``[today, today + horizon_days]`` (``horizon_days`` defaults to
``DEFAULT_HORIZON_DAYS``).
HOOK (Feature 09 presence services): the presence business layer that
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
presence-state write so a child's future instances track its presence:
``set_presence_pattern`` (including clearing/setting an empty pattern),
deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
``create_presence_override``, and ``delete_presence_override``. A
schedule delete changes presence state too (the child becomes
always-present), so it must regenerate just like the others. This hook
is documented, not yet wired: no presence business layer exists yet, and
the presence DAO (``dao_presence.py``) must stay free of business rules
— never call :func:`regenerate_for_child` from inside the DAO layer.
The materialization walk lives in
:mod:`custom_components.nestquest.core.materialize` (extracted in
task 510c1f78); this module re-exports it so existing
``from .materialize import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import datetime
from .const import DEFAULT_HORIZON_DAYS
from .dao_instances import QuestInstancesDao, _resolve_today
from .dao_rules import (
_validate_date,
load_materialization_input,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
)
from .db import NestQuestDatabase
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
from .recurrence import ScheduleRule, occurs_on
def _now_stamp() -> str:
"""Return the batch's shared UTC second-precision generation stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _resolve_horizon_days(horizon_days: int | None) -> int:
"""Validate a regeneration horizon and return the resolved day count.
``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool
(SQLite would happily bind ``True``/``False`` onto an int), a non-int,
or a value below 1 raises ValueError naming the field — the validation
runs BEFORE any future-instance delete so a malformed horizon can never
delete rows it then fails to rebuild. A negative horizon would
otherwise delete first and only then be rejected by
:func:`materialize`'s inverted-range check; ``0``/``False`` would
silently shrink the window to a single day.
"""
if horizon_days is None:
return DEFAULT_HORIZON_DAYS
if (
isinstance(horizon_days, bool)
or not isinstance(horizon_days, int)
or horizon_days < 1
):
raise ValueError(
f"horizon_days must be a positive integer, got {horizon_days!r}"
)
return horizon_days
def _decode_rule(snapshot) -> ScheduleRule:
"""Decode a snapshot's rule record back into a ScheduleRule.
The record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through ``ScheduleRulesDao``) preserves the coherence the snapshot
guarantees.
"""
return schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
def _build_engine(
schedules_records,
overrides_records,
) -> PresenceEngine:
"""Convert raw presence records into an immutable PresenceEngine."""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
child_id, record.anchor_date, record.pattern
)
overrides: dict[int, list[PresenceOverride]] = {}
for child_id, records in overrides_records.items():
overrides[child_id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in records
]
return PresenceEngine(schedules, overrides)
async def materialize(
database: NestQuestDatabase,
start_date: str,
end_date: str,
*,
child_ids: list[int] | None = None,
today: datetime.date | None = None,
) -> int:
"""Generate quest instances for the closed range [start_date, end_date].
``start_date`` and ``end_date`` must be strict ISO calendar dates
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
else raises ValueError naming the offending field. Returns the number
of instances upserted for the range (idempotent — a re-run refreshes
the same rows, never duplicates them).
``child_ids`` optionally scopes the walk to exactly those children:
assignees outside the list are never written, so a caller that only
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
after a presence change) cannot touch any unrelated child's rows.
When omitted (or None) the walk covers every assignee as before.
``today`` optionally pins the resolved HA-local date the walk's
no-past guard compares against; it is threaded into every
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
omitted the host clock (``datetime.date.today``) is used, which is the
historical default. A caller that computed the horizon in Home
Assistant's configured time zone MUST pass that same date here so a
time zone behind the host around midnight is not rejected as "past".
``start_date`` is clamped up to the resolved "today" (the same
``today`` anchor threaded into the no-past guard), so a caller that
passes a ``start_date`` already in the past never yields a
past-dated instance: the walk simply begins on today and skips every
earlier date. A range entirely in the past therefore produces zero
instances rather than an error, and the DAO's no-past guard remains
the authoritative backstop under the lock.
All inputs are read in ONE locked transaction; each tuple's write then
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
which re-validates the tuple and its current ``due_time`` in the same
transaction as the INSERT, so a config change that arrives mid-batch
skips the affected tuples rather than aborting the batch or leaving a
partial result.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
if end_date < start_date:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end_date!r} < {start_date!r}"
)
snapshots, schedules_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
engine = _build_engine(schedules_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids)
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows)
for snapshot in snapshots
]
generated_at = _now_stamp()
instances = QuestInstancesDao(database)
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
# Clamp a past start up to today (the HA-local anchor when the caller
# pinned one, else the host clock) so the walk can never emit a
# past-dated instance; the DAO's no-past guard remains the backstop.
# A range entirely in the past therefore yields zero instances.
today_date = _resolve_today(today)
start = max(start, today_date)
count = 0
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for definition_id, rule, assignees, windows in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if not engine.is_present(child.id, cursor):
continue
for window in windows:
written = await instances.upsert_if_valid(
definition_id,
child.id,
iso,
generated_at,
window=window.window,
due_time=window.due_time,
today=today,
)
if written is not None:
count += 1
cursor += datetime.timedelta(days=1)
return count
async def regenerate_for_definition(
database: NestQuestDatabase,
definition_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a definition's future instances after a config change.
The Feature 06 edit/assignment/activation paths call this after a
successful write so future instances track the new state: the
definition's open instances at or after today are deleted (never a
completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
then the materialization walk re-runs over the rolling horizon
``[today, today + horizon_days]`` so the (changed) rule,
assignee set and windows re-materialize against today's state.
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — so the delete cutoff and the
re-materialization range share one anchor, and the walk's
``upsert_if_valid`` no-past guard never rejects the regenerated
range. ``today`` optionally pins a caller-resolved HA-local date
(threaded into both the delete cutoff and the re-materialization)
so a household time zone behind the host around midnight stays
consistent. ``horizon_days`` sizes the re-materialization window;
when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`.
A bool, non-int, or sub-1 value raises ValueError BEFORE any delete.
Returns the number of instances the re-materialization
upserted across the whole snapshot (idempotent — other active
definitions' tuples refresh in place, never duplicate).
"""
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
start_date = today_date.isoformat()
end_date = (
today_date + datetime.timedelta(days=horizon)
).isoformat()
instances = QuestInstancesDao(database)
await instances.delete_future_uncompleted(
definition_id, start_date, today=today
)
return await materialize(database, start_date, end_date, today=today)
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
# child-presence counterpart to ``regenerate_for_definition``. The
# presence business layer that lands in Feature 09 MUST call
# ``regenerate_for_child`` after EVERY presence-state write, so future
# instances track a child's changed presence: set_presence_pattern
# (including clearing/setting an empty pattern), deleting/clearing the
# presence schedule (PresenceSchedulesDao.delete),
# create_presence_override, and delete_presence_override. A schedule
# delete changes presence state too (the child becomes always-present),
# so it must regenerate just like the others. It is deliberately NOT
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
# business rules), and no presence business layer exists yet to host the
# call — see the module docstring note above.
async def regenerate_for_child(
database: NestQuestDatabase,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> int:
"""Regenerate a child's future instances after a presence change.
The Feature 09 presence services call this after every
presence-state write — set_presence_pattern (including clearing or
setting an empty pattern), deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
create_presence_override, and delete_presence_override — so a
child's future instances track its new presence: the child's open
instances at or after today are deleted across ALL its definitions
(never a completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
then the materialization walk re-runs over the rolling horizon
``[today, today + horizon_days]`` scoped to that child only,
so the child's now-absent/present dates re-materialize against
today's presence WITHOUT touching any other child's rows.
``child_id`` must be a plain int — bool and float are rejected
(SQLite binds a bool as 0/1 and a float would round, so a malformed
id must never mutate the wrong profile).
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — and the re-materialization range uses the
delete's returned effective cutoff (see
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
so the delete cutoff and the re-materialization window share ONE
execution-day anchor even when the call is queued across midnight.
``today`` optionally pins a caller-resolved HA-local date, threaded
into the delete cutoff and the re-materialization for the same
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
re-materialization window; when omitted it falls back to
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
raises ValueError BEFORE any delete. Returns the number of instances
the re-materialization upserted for the child across the whole
snapshot.
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
horizon = _resolve_horizon_days(horizon_days)
today_date = _resolve_today(today)
instances = QuestInstancesDao(database)
# Use the delete's returned effective cutoff as the materialize anchor,
# NOT the independently-computed "today" above. If the call is queued
# across midnight the delete clamps its cutoff to the execution-day;
# materializing from the stale "today" (now yesterday) would trip the
# walk's no-past guard and leave the child's future instances deleted
# but not rebuilt. One anchor for both keeps them consistent.
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
child_id, today_date.isoformat(), today=today
)
start = datetime.date.fromisoformat(effective_cutoff)
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
return await materialize(
database, start.isoformat(), end_date, child_ids=[child_id], today=today
)
from .core.materialize import * # noqa: F401,F403
+7 -473
View File
@@ -1,477 +1,11 @@
"""The presence and custody model (Feature 05).
"""Re-export of :mod:`custom_components.nestquest.core.presence` for the integration package.
PURE module: no Home Assistant imports, no database access, no
timezone logic — it operates on plain calendar dates only (feature
guardrail). The later engine (cycle position, ``is_present``,
overrides, previews) builds directly on this model.
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
week is this child at this house?" for an N-week repeating custody
pattern. Two of the household's three children follow a two-week
rotation; a child with NO schedule row is present every day (that
default lives in the engine, not here).
Pattern encoding: the model's canonical serialization is the SAME
pipe-separated CSV the database stores (see schema.py's
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
empty segment means absent every day of that week. Encoding sorts
each segment's weekdays and uses the exact segment order, so
``decode(encode(schedule))`` round-trips losslessly and two schedules
with equal patterns produce equal strings.
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
computed from anchor-date arithmetic ONLY — never ISO week numbers or
week parity (D-004): 53-week years silently invert parity on January
1st. The arithmetic lives in the engine task; this module validates
that the anchor is a strict calendar date.
Validation policy: construction is total — every reject happens in
``__init__`` so no invalid schedule can ever exist:
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
the database CHECK (schema.py) and keeps anchor arithmetic and the
encoded pattern human-readable; revisit both together if a longer
cycle is ever needed.
- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly
(no gaps, no extra weeks) with sets of integers 0..6 (bools
rejected: True would silently mean Monday).
- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a
``datetime.date``).
The presence and custody model lives in
:mod:`custom_components.nestquest.core.presence` (extracted in
task 510c1f78); this module re-exports it so existing
``from .presence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from types import MappingProxyType
#: The schema's documented cap for cycle_length_weeks (schema.py keeps
#: the pattern human-readable and the anchor arithmetic small).
MAX_CYCLE_LENGTH_WEEKS = 4
_DATE_FORMAT = "%Y-%m-%d"
def _parse_date(value: object, field_name: str) -> datetime.date:
"""Accept a datetime.date or a strict YYYY-MM-DD string.
``datetime.datetime`` is rejected explicitly: it SUBCLASSES
datetime.date, so a bare isinstance check would store a value with
a time component and every later calendar-date comparison (e.g.
``PresenceOverride.covers``) would raise TypeError.
"""
if isinstance(value, datetime.datetime):
raise ValueError(
f"{field_name} must be a plain calendar date, not a "
f"datetime with a time component: {value!r}"
)
if isinstance(value, datetime.date):
return value
if not isinstance(value, str):
raise ValueError(
f"{field_name} must be a datetime.date or an ISO date string, "
f"got {value!r}"
)
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise ValueError(
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise ValueError(
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
)
return parsed
def _validate_week_index(week: object, cycle_length_weeks: int) -> int:
"""Reject non-int week indices (bools included) and out-of-cycle."""
if isinstance(week, bool) or not isinstance(week, int):
raise ValueError(
f"pattern week index must be an integer, got {week!r}"
)
if not 0 <= week < cycle_length_weeks:
raise ValueError(
f"pattern week index must be in 0..{cycle_length_weeks - 1}, "
f"got {week}"
)
return week
def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
"""Validate one week's present-weekday set."""
if isinstance(weekdays, (str, bytes)) or weekdays is None:
raise ValueError(
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
f"got {weekdays!r}"
)
try:
members = list(weekdays)
except TypeError:
raise ValueError(
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
f"got {weekdays!r}"
) from None
for member in members:
if isinstance(member, bool) or not isinstance(member, int):
raise ValueError(
f"pattern[{week}] weekday must be an int 0..6, got {member!r}"
)
if not 0 <= member <= 6:
raise ValueError(
f"pattern[{week}] weekday must be in 0..6 (Monday=0), "
f"got {member}"
)
return frozenset(members)
def _validate_cycle_length(value: object) -> int:
"""Reject non-int and out-of-range cycle lengths (bools included)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(
f"cycle_length_weeks must be an integer, got {value!r}"
)
if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS:
raise ValueError(
f"cycle_length_weeks must be between 1 and "
f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}"
)
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools would silently address child 1)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
@dataclass(frozen=True, init=False)
class PresenceSchedule:
"""One child's N-week repeating presence pattern.
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
to the set of present weekdays (ints, Monday=0). A week with an
empty set means the child is absent every day of that week — which
is distinct from having no schedule at all (the engine treats a
missing schedule as present every day).
"""
child_id: int
cycle_length_weeks: int
anchor_date: datetime.date
pattern: MappingProxyType # week index -> frozenset of weekdays
def __init__(
self,
child_id: int,
cycle_length_weeks: int,
anchor_date: object,
pattern: dict[int, object],
) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id))
cycle = _validate_cycle_length(cycle_length_weeks)
object.__setattr__(self, "cycle_length_weeks", cycle)
object.__setattr__(
self, "anchor_date", _parse_date(anchor_date, "anchor_date")
)
if not isinstance(pattern, dict):
raise ValueError(
"pattern must map week index to a set of present "
f"weekdays, got {pattern!r}"
)
normalised: dict[int, frozenset[int]] = {}
for week, weekdays in pattern.items():
index = _validate_week_index(week, cycle)
normalised[index] = _validate_weekday_set(index, weekdays)
missing = [
week for week in range(cycle) if week not in normalised
]
if missing:
raise ValueError(
f"pattern must cover week indices 0..{cycle - 1}; "
f"missing {missing}"
)
# Frozen must mean frozen: a bare dict would let callers inject
# unvalidated weekdays (or drop required weeks) AFTER
# construction, bypassing total validation. The mapping
# proxy exposes the validated contents read-only; the
# per-week sets are already frozensets.
object.__setattr__(self, "pattern", MappingProxyType(normalised))
def encode(self) -> str:
"""Serialize to the database's pipe-separated CSV shape.
Segments appear in week order; each segment is the week's
present weekdays as a sorted comma-separated list (an empty set
encodes as an empty segment). Two schedules with equal
patterns encode identically.
"""
segments = [
",".join(str(day) for day in sorted(self.pattern[week]))
for week in range(self.cycle_length_weeks)
]
return "|".join(segments)
@classmethod
def decode(
cls,
child_id: int,
anchor_date: object,
encoded: str,
) -> "PresenceSchedule":
"""Build a schedule from its encoded pattern.
The cycle length is derived from the segment count, mirroring
the database's segment-count CHECK. Decoding is strict: wrong
shapes, non-numeric or out-of-range weekdays are rejected —
the database CHECKs make such rows unrepresentable, and this
is the same contract on the model side. A segment count above
the documented cycle cap is rejected by the ordinary
construction validation.
"""
if not isinstance(encoded, str):
raise ValueError(
f"encoded pattern must be a string, got {encoded!r}"
)
segments = encoded.split("|")
pattern: dict[int, set[int]] = {}
for week, segment in enumerate(segments):
weekdays: set[int] = set()
if segment:
for part in segment.split(","):
if len(part) != 1 or part not in "0123456":
raise ValueError(
f"encoded segment {week} carries an invalid "
f"weekday {part!r} (single digits 0-6 only)"
)
weekdays.add(int(part))
pattern[week] = weekdays
return cls(child_id, len(segments), anchor_date, pattern)
@dataclass(frozen=True, init=False)
class PresenceOverride:
"""A date-specific present/absent flag beating any pattern.
Covers a single date (``end_date == start_date``) or an inclusive
range. A concrete range is the storage shape
(``presence_overrides.end_date`` is NOT NULL), so the model
requires both bounds and validates start <= end. Construction and
validation live here so the engine can trust every instance.
"""
child_id: int
start_date: datetime.date
end_date: datetime.date
is_present: bool
note: str | None
def __init__(
self,
child_id: int,
start_date: object,
end_date: object,
is_present: bool,
note: str | None = None,
) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id))
start = _parse_date(start_date, "start_date")
end = _parse_date(end_date, "end_date")
if end < start:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end.isoformat()} < {start.isoformat()}"
)
object.__setattr__(self, "start_date", start)
object.__setattr__(self, "end_date", end)
if not isinstance(is_present, bool):
raise ValueError(
f"is_present must be a real bool, got {is_present!r}"
)
object.__setattr__(self, "is_present", is_present)
if note is not None and not isinstance(note, str):
raise ValueError(f"note must be a string or None, got {note!r}")
object.__setattr__(self, "note", note)
def covers(self, date: datetime.date) -> bool:
"""Return True when the override applies to ``date``."""
return self.start_date <= date <= self.end_date
def cycle_week_index(
schedule: PresenceSchedule, target_date: object
) -> int:
"""Return the cycle week index ``target_date`` falls in.
Anchor-date arithmetic ONLY (D-004): ``((target_date -
anchor_date).days // 7) % cycle_length_weeks``. Python's floor
division makes dates BEFORE the anchor behave correctly — they
walk the cycle backwards (day -1 is the last week of the cycle,
day -8 wraps one further) instead of inverting, which is exactly
what ISO week parity does wrong in 53-week years. The modulo of
a negative in Python is non-negative, so the index is always a
valid week index.
"""
target = _parse_date(target_date, "target_date")
days = (target - schedule.anchor_date).days
return (days // 7) % schedule.cycle_length_weeks
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
#: years of daily checks. A child absent every day (an all-absent
#: pattern with a blocking override) would otherwise loop forever; the
#: cap bounds the scan and surfaces the situation as an error instead.
MAX_PREVIEW_SCAN_DAYS = 366 * 2
class PresenceEngine:
"""Answers "is this child at this house on this date?".
Pure and side-effect-free: built from an immutable snapshot of the
children's schedules (``child_id -> PresenceSchedule``), it never
touches the database — callers build a snapshot from the DAO layer
and pass it in. A child with NO schedule entry is present every
day (the third household child's case); a child WITH a schedule is
present exactly when the date's weekday is in that schedule's
pattern for the date's cycle week index (anchor-date arithmetic —
:func:`cycle_week_index`, never ISO week parity).
"""
def __init__(
self,
schedules: dict[int, PresenceSchedule],
overrides: dict[int, list[PresenceOverride]] | None = None,
) -> None:
if not isinstance(schedules, dict):
raise ValueError(
f"schedules must map child_id to PresenceSchedule, got "
f"{schedules!r}"
)
for child_id, schedule in schedules.items():
# Validate the KEY as a model child id too: True and 1.0
# compare equal to 1, so {True: schedule(1)} would be
# accepted and then behave differently on lookup.
_validate_child_id(child_id)
if not isinstance(schedule, PresenceSchedule):
raise ValueError(
f"schedules[{child_id!r}] must be a PresenceSchedule, "
f"got {schedule!r}"
)
if schedule.child_id != child_id:
raise ValueError(
f"schedules is keyed {child_id!r} but holds the "
f"schedule of child {schedule.child_id}"
)
if overrides is None:
overrides = {}
if not isinstance(overrides, dict):
raise ValueError(
"overrides must map child_id to a list of "
f"PresenceOverride, got {overrides!r}"
)
for child_id, entries in overrides.items():
_validate_child_id(child_id)
if not isinstance(entries, list):
raise ValueError(
f"overrides[{child_id!r}] must be a list of "
f"PresenceOverride, got {entries!r}"
)
for entry in entries:
if not isinstance(entry, PresenceOverride):
raise ValueError(
f"overrides[{child_id!r}] entries must be "
f"PresenceOverride, got {entry!r}"
)
if entry.child_id != child_id:
raise ValueError(
f"overrides is keyed {child_id!r} but holds an "
f"override of child {entry.child_id}"
)
# Read-only snapshot: reassignment is refused and no mutator
# exists, so a built engine answers consistently for its
# lifetime. The overrides lists are copied to immutable
# tuples — a shallow mapping copy alone would leave caller-
# owned lists reachable through _overrides, and mutating one
# later would change is_present results.
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
object.__setattr__(
self,
"_overrides",
MappingProxyType(
{child_id: tuple(entries) for child_id, entries in overrides.items()}
),
)
def is_present(self, child_id: object, date: object) -> bool:
"""Return whether ``child_id`` is present on ``date``.
Overrides take precedence over the repeating pattern: when an
override covers the date, its flag IS the answer — including
for a child with no schedule at all. The DAO layer guarantees
a child's overrides never overlap; if a snapshot still carries
two covering the same date, the one with the LATEST start date
wins (deterministic, and matches "the most specific swap wins").
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
target = _parse_date(date, "date")
entries = self._overrides.get(child_id) or []
covering = [
entry for entry in entries if entry.covers(target)
]
if covering:
winner = max(covering, key=lambda entry: entry.start_date)
return winner.is_present
schedule = self._schedules.get(child_id)
if schedule is None:
# No schedule at all: present every day (household default).
return True
week = cycle_week_index(schedule, target)
return target.weekday() in schedule.pattern[week]
def next_present_dates(
self,
child_id: object,
start_date: object,
count: int,
) -> list[datetime.date]:
"""Return the next ``count`` dates the child is present.
Walks forward from ``start_date`` (INCLUSIVE — a parent
previewing "starting Monday" expects Monday itself when the
child is present) checking :meth:`is_present` for every day,
so overrides and the pattern are honoured identically. The
admin Schedule tab renders this preview so a parent can confirm
a custody pattern is right before saving it.
A child absent every scanned day raises ValueError after the
two-year search cap — an always-absent child would otherwise
loop forever. ``count`` must be a positive integer.
"""
_validate_child_id(child_id)
start = _parse_date(start_date, "start_date")
if isinstance(count, bool) or not isinstance(count, int):
raise ValueError(f"count must be an integer, got {count!r}")
if count < 1:
raise ValueError(f"count must be at least 1, got {count}")
found: list[datetime.date] = []
day = start
for _ in range(MAX_PREVIEW_SCAN_DAYS):
if self.is_present(child_id, day):
found.append(day)
if len(found) == count:
return found
day += datetime.timedelta(days=1)
raise ValueError(
f"child {child_id} is not present on any of the "
f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; "
f"{count} present day(s) cannot be previewed"
)
def __setattr__(self, name: object, value: object) -> None:
raise AttributeError(
"PresenceEngine is an immutable snapshot; build a new engine "
"instead of mutating this one"
)
from .core.presence import * # noqa: F401,F403
@@ -1,603 +1,11 @@
"""Business layer for creating and editing quest definitions (Feature 06).
"""Re-export of :mod:`custom_components.nestquest.core.quest_definitions` for the integration package.
Sits between the Feature 09 service gate and the typed
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path,
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
Validation policy mirrors :mod:`.children`:
- ``title`` is required and trimmed; empty or whitespace-only raises
ValueError.
- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
(its constructor is total — rule validation is NOT re-implemented
here).
- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``);
every id is a plain int (bools and floats rejected, since SQLite would
bind them onto a real child) and must reference an existing ACTIVE
child (the DAO enforces the active/exists check inside the same
transaction). Duplicate ids are rejected here, before the composite
primary key could surface them as an :class:`sqlite3.IntegrityError`.
- ``windows`` must be a non-empty list (strictly a ``list``); each entry
is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare
name (no due time) or a ``(name, due_time)`` pair whose due time is a
strict 24-hour HH:MM string. Duplicate window names are rejected
here, before the composite primary key could surface them as an
:class:`sqlite3.IntegrityError`.
The rule, definition, assignees and windows are persisted in ONE
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
so a rejected or failed create persists nothing. The definition-level
``due_time`` column is superseded by per-window due times (D-008) and is
never set here. ``created_at`` is stamped UTC ISO-8601 at second
precision, matching :mod:`.children`.
The quest definitions business layer lives in
:mod:`custom_components.nestquest.core.quest_definitions` (extracted in
task 510c1f78); this module re-exports it so existing
``from .quest_definitions import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import ChildRecord, _connection_lock
from .dao_rules import (
QuestDefinitionRecord,
QuestDefinitionSnapshot,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
ScheduleRuleStorage,
ScheduleRulesDao,
_UNSET,
_validate_date,
_validate_time,
_validate_window,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
from .materialize import regenerate_for_definition
from .recurrence import ScheduleRule, occurs_on
@dataclass(frozen=True)
class CreatedQuestDefinition:
"""The persisted result of a create, edit or activation change.
``definition`` is the stored row; ``rule`` is the schedule rule
decoded back through the storage mapping; ``assignees`` and
``windows`` are the definition's linked child records and window
declarations, read through the DAO.
"""
definition: QuestDefinitionRecord
rule: ScheduleRule
assignees: list[ChildRecord]
windows: list[QuestDefinitionWindowRecord]
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_text(
value: object, field: str, *, required: bool = False
) -> str | None:
"""Validate a free-text field and return it trimmed.
``None`` raises when ``required`` and otherwise returns None. A
non-string value is rejected; an empty result is rejected for
required fields.
"""
if value is None:
if required:
raise ValueError(f"{field} is required")
return None
if not isinstance(value, str):
raise ValueError(f"{field} must be a string, got {value!r}")
trimmed = value.strip()
if required and not trimmed:
raise ValueError(f"{field} is required")
return trimmed
def _validate_assignee_ids(value: object) -> list[int]:
"""Return the assignee ids as plain ints, or raise naming the field.
The argument must be a non-empty list — strictly a ``list``: tuples
and other sequences are rejected. Each entry must be a real int
(bools and floats rejected) so SQLite cannot bind ``True`` onto
child 1 or a float onto a neighbouring profile.
"""
if type(value) is not list:
raise ValueError(
f"assignee_child_ids must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("assignee_child_ids must not be empty")
ids: list[int] = []
for entry in value:
if isinstance(entry, bool) or not isinstance(entry, int):
raise ValueError(
"assignee_child_ids must contain integer child ids, "
f"got {entry!r}"
)
ids.append(entry)
if len(set(ids)) != len(ids):
raise ValueError(
"assignee_child_ids must not contain duplicate child ids"
)
return ids
def _normalize_windows(
value: object,
) -> list[tuple[str, str | None]]:
"""Normalize the windows argument to ``(name, due_time)`` pairs.
The outer argument must be a non-empty list — strictly a ``list``:
tuples and other sequences are rejected, while each individual entry
may still be a ``(window, due_time)`` tuple. A bare window name
(due time None) is also accepted. The name must be a
:data:`~.const.QUEST_WINDOWS` spelling and a present due time must be
a strict 24-hour HH:MM, both validated up front so a malformed
window is rejected before any write.
"""
if type(value) is not list:
raise ValueError(
f"windows must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("windows must not be empty")
normalized: list[tuple[str, str | None]] = []
seen: set[str] = set()
for index, entry in enumerate(value):
if isinstance(entry, str):
name: object = entry
due_time: object = None
elif isinstance(entry, (tuple, list)) and len(entry) == 2:
name, due_time = entry[0], entry[1]
else:
raise ValueError(
f"windows[{index}] must be a window name or a "
f"(name, due_time) pair, got {entry!r}"
)
_validate_window(name)
if due_time is not None:
_validate_time(due_time, "due_time")
if name in seen:
raise ValueError(
f"windows must not contain duplicate window {name!r}"
)
seen.add(name)
normalized.append((name, due_time))
return normalized
def _validate_definition_id(value: object) -> int:
"""Reject non-int definition ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address definition 1 and a float would round — malformed service
input must never mutate the wrong definition.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"definition_id must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def create_quest_definition(
database: NestQuestDatabase,
title: str,
rule: ScheduleRule,
assignee_child_ids: list[int],
windows: list[str | tuple[str, str | None]],
*,
description: str | None = None,
icon: str | None = None,
) -> CreatedQuestDefinition:
"""Create a quest definition (rule + assignees + windows) atomically.
Validates the arguments up front, then persists the schedule rule,
the definition, its assignees and its windows in one transaction via
the DAO. Returns the stored definition together with its decoded
rule, assignees and windows — all read back inside that same
transaction, so the returned bundle is a consistent creation
snapshot. Raises ValueError on any rejected argument; a rejected or
failed create persists nothing.
"""
name = _validate_text(title, "title", required=True)
assert name is not None
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
assignee_ids = _validate_assignee_ids(assignee_child_ids)
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
storage = schedule_rule_to_storage(rule)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.create_with_rule_and_windows(
name,
storage,
_now_stamp(),
assignee_ids,
window_specs,
description=description_value,
icon=icon_value,
)
except ValueError as error:
# The DAO's assignee check names only the child; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(
f"assignee_child_ids: {message}"
) from error
raise
decoded_rule = schedule_rule_from_storage(storage)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def edit_quest_definition(
database: NestQuestDatabase,
definition_id: int,
*,
title: str | object = _UNSET,
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule and windows atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
(required and trimmed); ``description`` and ``icon`` are optional
and may be cleared by passing ``None`` explicitly. A provided
``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
and REPLACES the definition's schedule rule IN PLACE — the existing
``schedule_rules`` row is updated, never orphaned or duplicated. A
provided ``windows`` list REPLACES the whole window set (each entry
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
so the returned bundle is a consistent edit snapshot. Edits change
future instances only; no ``quest_instances`` or
``completion_events`` row is touched.
``today`` optionally pins the caller-resolved HA-local date the
regeneration's no-past guard and horizon are anchored to (threaded
into :func:`~.materialize.regenerate_for_definition`); it is a plain
``datetime.date`` with no HA import here — the Feature 09 service
supplies the hass-derived value. When omitted, regeneration falls
back to the host clock, same as before. ``horizon_days`` sizes the
regeneration's re-materialization window the same way (threaded into
:func:`~.materialize.regenerate_for_definition`); it defaults to
:data:`~.const.DEFAULT_HORIZON_DAYS` when omitted.
"""
_validate_definition_id(definition_id)
title_value: str | None | object = _UNSET
if title is not _UNSET:
name = _validate_text(title, "title", required=True)
assert name is not None
title_value = name
description_value: str | None | object = _UNSET
if description is not _UNSET:
description_value = _validate_text(description, "description")
icon_value: str | None | object = _UNSET
if icon is not _UNSET:
icon_value = _validate_text(icon, "icon")
rule_storage: ScheduleRuleStorage | object = _UNSET
if rule is not _UNSET:
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
rule_storage = schedule_rule_to_storage(rule)
window_specs: list[tuple[str, str | None]] | object = _UNSET
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.edit_definition(
definition_id,
title=title_value,
description=description_value,
icon=icon_value,
rule=rule_storage,
windows=window_specs,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def assign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> list[ChildRecord]:
"""Assign ``child_id`` to the definition (idempotent).
Validates that both ids are plain ints (bools and floats rejected)
and that the child exists and is active — the DAO enforces the
active/exists check inside its transaction, and also confirms the
definition exists. Assigning an already-assigned child is a no-op
(no duplicate row). Returns the definition's assignees read back
inside the DAO's same transaction, a consistent post-assignment
roster (a concurrent assign/unassign cannot race it after the
write). Raises ValueError on any rejected argument. Only the
``quest_definition_assignees`` link is written; existing instances
and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the assignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
try:
assignees = await dao.add_assignee(definition_id, child_id)
except ValueError as error:
# The DAO names only the child/definition; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(f"child_id: {message}") from error
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return assignees
async def unassign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> bool:
"""Unassign ``child_id`` from the definition; True when removed.
Validates both ids are plain ints (bools and floats rejected) and
that the definition exists, then removes the (definition, child)
link. Returns True when a row was removed and False when the child
was not currently assigned. Raises ValueError on a rejected
argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the unassignment's regeneration (see :func:`edit_quest_definition`).
``horizon_days`` optionally sizes the regeneration's re-materialization
window the same way.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
definition = await dao.get(definition_id)
if definition is None:
raise ValueError(
f"definition_id: quest definition {definition_id} does not exist"
)
removed = await dao.remove_assignee(definition_id, child_id)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return removed
async def set_quest_definition_active(
database: NestQuestDatabase,
definition_id: int,
is_active: bool,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> CreatedQuestDefinition:
"""Deactivate or reactivate a definition; returns the updated view.
Flipping the flag is the only removal path (Feature 06 guardrail):
a deactivated definition stops future instance generation but its
row, schedule rule, assignees and windows all survive, and existing
instances plus completion history are never touched. Reactivation
re-enables future generation; generating or removing instances is
Feature 07 and does not happen here.
``definition_id`` must be a plain int (bools and floats rejected,
since SQLite would bind ``True`` onto definition 1) and
``is_active`` must be a real bool — the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError naming the
offending field when the id is malformed, ``is_active`` is not a
bool, or the definition does not exist.
Atomicity: the existence check, the UPDATE and the readback of the
definition, its rule, assignees and windows all run inside the
connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race.
``today`` optionally pins the caller-resolved HA-local date threaded
into the activation change's regeneration (see
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
regeneration's re-materialization window the same way.
"""
_validate_definition_id(definition_id)
if not isinstance(is_active, bool):
raise ValueError(f"is_active must be a real bool, got {is_active!r}")
async with _connection_lock(database):
dao = QuestDefinitionsDao(database)
if await dao.get(definition_id) is None:
raise ValueError(
f"definition_id: quest definition {definition_id} "
"does not exist"
)
await dao.set_active(definition_id, is_active)
updated = await dao.get(definition_id)
assert updated is not None
rule_record = await ScheduleRulesDao(database).get(
updated.schedule_rule_id
)
assert rule_record is not None
assignees = await dao.list_assignees(definition_id)
windows = await dao.list_windows(definition_id)
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(rule_record)
)
await regenerate_for_definition(
database, definition_id, today=today, horizon_days=horizon_days
)
return CreatedQuestDefinition(
definition=updated,
rule=rule,
assignees=assignees,
windows=windows,
)
def _bundle_snapshot(
snapshot: QuestDefinitionSnapshot,
) -> CreatedQuestDefinition:
"""Decode a DAO snapshot into the layer's rich view.
The rule record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
coherence the DAO's locked snapshot guarantees: the rule, assignees
and windows all come from one atomic read, never three separate ones.
"""
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def list_active_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return every ACTIVE definition with its decoded rule and links.
Ordered by rising definition id, matching the DAO's ``list_active``
order, so callers get a stable, repeatable sequence. Each entry is
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
the layer: the stored row plus its decoded
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
definitions and their links are read by the DAO inside one locked
transaction, so each bundle is a coherent snapshot that a concurrent
edit cannot interleave.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
) -> list[CreatedQuestDefinition]:
"""Return the definitions assigned to ``child_id`` (rich view).
``child_id`` must be a plain int (bools and floats rejected, since
SQLite would bind ``True`` onto child 1). Results are ordered by
rising definition id, matching ``list_active_definitions`` and
``list_definitions_firing_on``. Each entry is a
:class:`CreatedQuestDefinition` snapshot bundling the definition with
its decoded rule, assignees and windows, read atomically by the DAO.
"""
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_by_child(child_id)
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return sorted(bundles, key=lambda bundle: bundle.definition.id)
async def list_definitions_firing_on(
database: NestQuestDatabase,
date: str,
) -> list[CreatedQuestDefinition]:
"""Return the ACTIVE definitions whose rule fires on ``date``.
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
value raises ValueError naming the field. Each active definition's
stored rule is decoded through the task-1 storage mapping and handed
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
definitions whose decoded rule fires on the date are returned, in
rising definition-id order, each as the same rich
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
The definitions and their rules are read inside one locked
transaction, so a bundle can never filter on one rule while
presenting another state's windows.
"""
_validate_date(date, "date")
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
from .core.quest_definitions import * # noqa: F401,F403
+7 -42
View File
@@ -1,46 +1,11 @@
"""Explicit admin-only versus open service registry (Feature 09).
"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package.
One mapping, one file: every registered ``nestquest`` service name is
either ``open`` or ``admin_only``. The permission decorator (later
Feature 09 tasks) reads this table; it does not take per-handler
policy arguments.
The service policy registry lives in
:mod:`custom_components.nestquest.core.service_policy` (extracted in
task 510c1f78); this module re-exports it so existing
``from .service_policy import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .const import (
DOMAIN_SERVICES,
SERVICE_COMPLETE_QUEST,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_UPDATE_QUEST_DEFINITION,
)
OPEN = "open"
ADMIN_ONLY = "admin_only"
SERVICE_POLICY: dict[str, str] = {
SERVICE_COMPLETE_QUEST: OPEN,
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
SERVICE_REGENERATE: ADMIN_ONLY,
}
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
raise RuntimeError(
"SERVICE_POLICY must name exactly the registered nestquest services, "
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
)
from .core.service_policy import * # noqa: F401,F403
+2 -2
View File
@@ -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] = []
+4 -1
View File
@@ -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):