Compare commits

..
Author SHA1 Message Date
overseer 5c7c07910a feat: derive instance state from latest completion event 2026-09-18 06:34:47 -04:00
20 changed files with 76 additions and 4153 deletions
+26 -64
View File
@@ -20,18 +20,15 @@ from .const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
CONF_UPDATE_INTERVAL,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
SERVICE_REGENERATE,
)
from .coordinator import NestQuestCoordinator
from .db import NestQuestDatabase
from .materialize import materialize as _materialize_run
from .migrations import apply_migrations
from .services import async_deregister_services, async_register_services
from .store import async_get_db_path
from .admin_allowlist import seed_setup_admin
@@ -241,7 +238,6 @@ class NestQuestRuntimeData:
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
remove_time_change_listener: Callable[[], Any]
coordinator: Any = None
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
@@ -360,8 +356,21 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
return None
def _make_regenerate_handler(hass: HomeAssistant):
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
def _register_regenerate_service(hass: HomeAssistant) -> None:
"""Register ``nestquest.regenerate`` once at domain scope.
The service triggers a full on-demand regeneration over the rolling
horizon, running the SAME idempotent materialize path
(:func:`_run_horizon_materialization`) as the startup backfill and the
daily rollover listener. It is DOMAIN-global — registered exactly once
(guarded by ``has_service``) and NOT bound to any config entry — so the
handler resolves the currently-live database at call time and a name
collision or duplicate setup is impossible. Removal is handled by the
unload path only once the LAST entry is removed (see
:func:`_deregister_regenerate_service`).
"""
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
return
async def _regenerate(_call: Any) -> None:
runtime_data = _find_live_runtime_data(hass)
@@ -376,29 +385,13 @@ def _make_regenerate_handler(hass: HomeAssistant):
hass, runtime_data.database, horizon_days
)
return _regenerate
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
def _register_services(hass: HomeAssistant) -> None:
"""Register domain-global NestQuest services once.
Services are DOMAIN-global — registered exactly once (guarded by
``has_service`` on regenerate) and NOT bound to any config entry —
so each handler resolves the currently-live database at call time
and a name collision or duplicate setup is impossible. Removal is
handled by the unload path only once the LAST entry is removed
(see :func:`_deregister_services`).
"""
async_register_services(
hass,
find_runtime=_find_live_runtime_data,
regenerate_handler=_make_regenerate_handler(hass),
)
def _deregister_services(hass: HomeAssistant) -> None:
"""Remove domain-global NestQuest services when the last entry unloads."""
async_deregister_services(hass)
def _deregister_regenerate_service(hass: HomeAssistant) -> None:
"""Remove ``nestquest.regenerate`` when the last entry unloads."""
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
hass.services.async_remove(DOMAIN, SERVICE_REGENERATE)
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
@@ -460,7 +453,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
database = await _async_open_database(hass, await async_get_db_path(hass))
remove_update_listener: Callable[[], Any] | None = None
remove_time_change_listener: Callable[[], Any] | None = None
coordinator: Any = None
try:
# Seed the admin allowlist on first setup so the owner is never
# locked out: an empty database takes the persisted admin copy
@@ -497,20 +489,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await _run_horizon_materialization(
hass, database, _configured_horizon_days(dict(entry.options))
)
_register_services(hass)
# The shared Feature 10 coordinator: one refresh cycle every
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
# five minutes). Created AFTER services so a failure below
# unwinds them through the except path's listener handling.
coordinator = NestQuestCoordinator(
hass,
entry_id=entry.entry_id,
database=database,
update_interval_seconds=entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
)
await coordinator.async_config_entry_first_refresh()
_register_regenerate_service(hass)
except BaseException:
# A failure anywhere after the database is open must not leak the
# listeners already registered against that (now-closed) database:
@@ -529,15 +508,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await result
except BaseException:
pass
# A coordinator created before the failure holds entity
# listeners pointed at the database being closed below —
# shut it down FIRST so no scheduled refresh can ever run
# against a closed connection.
if coordinator is not None:
try:
await coordinator.async_shutdown()
except BaseException:
pass
await database.close()
raise
assert remove_update_listener is not None
@@ -548,7 +518,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
coordinator=coordinator,
)
entry.runtime_data = runtime_data
hass.data[DOMAIN][entry.entry_id] = runtime_data
@@ -593,18 +562,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
# Tear the shared coordinator down after the DB close: no
# listener may outlive the connection it reads.
coordinator = getattr(runtime_data, "coordinator", None)
if coordinator is not None:
shutdown = getattr(coordinator, "async_shutdown", None)
if shutdown is not None:
await shutdown()
# Domain-global services are torn down only once the FINAL entry
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
# entry is still loaded.
# The ``regenerate`` service is DOMAIN-global: it is torn down only
# once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty),
# never when a sibling entry is still loaded.
if is_last:
_deregister_services(hass)
_deregister_regenerate_service(hass)
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
if unload_error is not None:
+18 -328
View File
@@ -4,14 +4,11 @@ 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.
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing and
un-completing an instance (no-op-if-already-done) arrive in later
tasks. Instance current state is derived from the latest event via
:func:`instance_state` (``open`` / ``done``); there is no status
column.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
@@ -28,16 +25,14 @@ 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_children import _connection_lock
from .dao_instances import (
EVENT_COMPLETED,
EVENT_UNCOMPLETED,
CompletionEventRecord,
CompletionEventsDao,
QuestInstanceRecord,
QuestInstancesDao,
)
from .db import NestQuestDatabase
@@ -67,168 +62,6 @@ def _stamp_occurred_at(now: datetime.datetime | None) -> str:
)
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):
@@ -339,21 +172,15 @@ async def append_event(
async def instance_state(
database: NestQuestDatabase,
instance_id: int,
*,
today: datetime.date | None = None,
) -> str:
"""Return ``open``, ``done``, or ``missed`` from the latest event.
"""Return ``open`` or ``done`` from the instance's 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.
No events, or a latest event of uncompleted, is ``open``. A latest
event of completed is ``done``. 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:
@@ -363,148 +190,11 @@ async def instance_state(
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
return _derived_state(instance, latest, today_date)
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,
) -> str:
"""Append a completed event unless the instance is already done.
Returns the derived state ``done``. An already-done instance is a
no-op: nothing is appended. 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
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
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}"
)
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 "done"
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}"
)
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=_was_on_time(instance, now, today),
now=now,
)
return "done"
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,
) -> str:
"""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``). An
already-open instance is a no-op: nothing is appended. 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 _derived_state(instance, latest, today_date)
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 _derived_state(instance, latest, today_date)
async def list_missed_for_child(
database: NestQuestDatabase,
child_id: int,
range_start: str,
range_end: str,
*,
today: datetime.date | None = None,
) -> list[QuestInstanceRecord]:
"""Return the child's missed instances due in the closed date range.
Missed means the latest event state is open and ``due_date`` is
before ``today`` (HA-local, threaded). Uses
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
Never appends a missed event. Instances and latest events are
read inside the connection lock so the list is one snapshot.
"""
child_id = _validate_int_id(child_id, "child_id")
today_date = _resolve_today(today)
async with _connection_lock(database):
instances = await QuestInstancesDao(database).list_by_date_range(
child_id, range_start, range_end
)
events = CompletionEventsDao(database)
missed: list[QuestInstanceRecord] = []
for instance in instances:
latest = await events.get_latest_for_instance(instance.id)
if _derived_state(instance, latest, today_date) == "missed":
missed.append(instance)
return missed
+1 -33
View File
@@ -9,34 +9,9 @@ LOGGER = logging.getLogger(LOGGER_NAME)
PLATFORMS: list[str] = []
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
#: plus the existing Feature 07 regenerate service).
SERVICE_COMPLETE_QUEST = "complete_quest"
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
SERVICE_MANAGE_CHILD = "manage_child"
#: Service name for the on-demand regeneration service (``nestquest.regenerate``).
SERVICE_REGENERATE = "regenerate"
DOMAIN_SERVICES: tuple[str, ...] = (
SERVICE_COMPLETE_QUEST,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_UPDATE_QUEST_DEFINITION,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
)
SQLITE_DB_FILENAME = "nestquest.db"
# Configuration keys and default values
@@ -55,13 +30,6 @@ DEFAULT_PANEL_IDLE_TIMEOUT = 300
# list as a disaster-recovery copy so a lost database can be re-seeded.
CONF_ADMIN_USER_IDS = "admin_user_ids"
# Shared coordinator refresh interval (Feature 10), in SECONDS. Every
# entity reads one snapshot per refresh; completions push an immediate
# refresh so this poll is only the fallback cadence.
CONF_UPDATE_INTERVAL = "update_interval"
DEFAULT_UPDATE_INTERVAL = 300
MIN_UPDATE_INTERVAL = 30
# Quest windows (D-008): a definition may declare several day windows;
# each produces its own instance per day and groups the panel's Quest
# Log into three columns. The clock ranges are stored here so they stay
-254
View File
@@ -1,254 +0,0 @@
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
ONE refresh pass builds the whole entity snapshot: active children,
today's instances with their derived states (open / done / missed via
:func:`~.completion.derive_state`), each child's presence today, and
per-child rollup counts. Every Feature 10 entity reads
``coordinator.data`` — no entity queries the database itself, so one
refresh updates the whole board coherently.
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
host clock and never UTC, matching the materialization and completion
layers.
Presence is resolved through the SAME engine the materializer uses
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
with the public DAO methods and the engine's anchor-date arithmetic
answers ``is this child at this house today`` (D-004 — never ISO week
parity).
``cycle_day`` reports where today falls in the custody cycle of the
first active child (display order) that HAS a schedule — 1-based day
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
anchor date. ``0`` means no active child has a schedule at all, so
the admin header degrades to a harmless zero instead of guessing.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .completion import derive_state
from .const import (
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
MIN_UPDATE_INTERVAL,
)
from .dao_children import ChildrenDao
from .dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from .dao_rules import QuestDefinitionsDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .db import NestQuestDatabase
from .presence import (
PresenceEngine,
PresenceOverride,
PresenceSchedule,
)
@dataclass(frozen=True)
class QuestInstanceView:
"""One instance plus everything the entities render from it."""
instance_id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_date: str
due_time: str | None
state: str # open | done | missed
overdue: bool
completed_at: str | None
was_on_time: bool | None
@dataclass(frozen=True)
class ChildDaySnapshot:
"""One active child's day: instances, presence, and rollup counts."""
child_id: int
child_name: str
instances: tuple[QuestInstanceView, ...]
present: bool
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
@dataclass(frozen=True)
class NestQuestSnapshot:
"""The coordinator's whole-entity data payload."""
today_iso: str
children: tuple[ChildDaySnapshot, ...]
cycle_day: int
class NestQuestCoordinator(DataUpdateCoordinator):
"""Shared refresh cycle for every NestQuest entity."""
def __init__(
self,
hass: HomeAssistant,
*,
entry_id: str,
database: NestQuestDatabase,
update_interval_seconds: int | None = None,
) -> None:
interval = update_interval_seconds
if (
not isinstance(interval, int)
or isinstance(interval, bool)
or interval < MIN_UPDATE_INTERVAL
):
interval = DEFAULT_UPDATE_INTERVAL
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=datetime.timedelta(seconds=interval),
)
self.entry_id = entry_id
self.database = database
def _local_now(self) -> datetime.datetime:
time_zone = ZoneInfo(self.hass.config.time_zone)
return datetime.datetime.now(time_zone)
async def _async_update_data(self) -> NestQuestSnapshot:
now = self._local_now()
today = now.date()
today_iso = today.isoformat()
children = await ChildrenDao(self.database).list_active()
schedules: dict[int, PresenceSchedule] = {}
overrides: dict[int, list[PresenceOverride]] = {}
for child in children:
schedule = await PresenceSchedulesDao(self.database).get_by_child(
child.id
)
if schedule is not None:
schedules[child.id] = PresenceSchedule.decode(
child.id, schedule.anchor_date, schedule.pattern
)
todays = await PresenceOverridesDao(
self.database
).list_by_child_and_range(child.id, today_iso, today_iso)
if todays:
# The engine validates MODEL objects, not DAO records;
# each record converts losslessly (the schema guarantees
# end >= start and a real is_present).
overrides[child.id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in todays
]
engine = PresenceEngine(schedules, overrides)
instances_dao = QuestInstancesDao(self.database)
events_dao = CompletionEventsDao(self.database)
definitions_dao = QuestDefinitionsDao(self.database)
snapshots: list[ChildDaySnapshot] = []
for child in children:
instances = await instances_dao.list_by_date_range(
child.id, today_iso, today_iso
)
views: list[QuestInstanceView] = []
for instance in instances:
latest = await events_dao.get_latest_for_instance(instance.id)
definition = await definitions_dao.get(instance.definition_id)
state = derive_state(instance, latest, today)
overdue = False
if state == "open" and instance.due_date == today_iso:
if instance.due_time is not None:
due_moment = datetime.datetime.strptime(
instance.due_time, "%H:%M"
).time()
overdue = now.time() > due_moment
views.append(
QuestInstanceView(
instance_id=instance.id,
definition_id=instance.definition_id,
child_id=instance.child_id,
title=definition.title if definition else "Quest",
icon=definition.icon if definition else None,
window=instance.window,
due_date=instance.due_date,
due_time=instance.due_time,
state=state,
overdue=overdue,
completed_at=(
latest.occurred_at
if latest is not None
and latest.event_type == "completed"
else None
),
was_on_time=(
latest.was_on_time
if latest is not None
and latest.event_type == "completed"
else None
),
)
)
due = len(views)
completed = sum(1 for view in views if view.state == "done")
remaining = due - completed
snapshots.append(
ChildDaySnapshot(
child_id=child.id,
child_name=child.display_name,
instances=tuple(views),
present=engine.is_present(child.id, today),
due_today=due,
completed_today=completed,
remaining_today=remaining,
completion_pct=(
round(completed * 100 / due) if due else 100
),
)
)
return NestQuestSnapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(schedules, children, today),
)
def _cycle_day(
schedules: dict[int, PresenceSchedule], children, today: datetime.date
) -> int:
"""Return today's 1-based day in the first scheduled child's cycle.
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
the schedule's own anchor, never ISO week numbers or parity, so a
53-week year cannot silently invert the cycle.
"""
for child in children:
schedule = schedules.get(child.id)
if schedule is None:
continue
days = (today - schedule.anchor_date).days
cycle_length = schedule.cycle_length_weeks * 7
return (days % cycle_length) + 1
return 0
@@ -270,15 +270,6 @@ class PresenceOverridesDao:
)
return [_override_from_row(row) for row in rows]
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
"""Return one override by id, or None when it does not exist."""
row = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE id = ?",
(override_id,),
)
return _override_from_row(row) if row is not None else None
async def delete(self, override_id: int) -> bool:
"""Remove one override by id; True if a row was removed."""
result = await self._database.execute(
@@ -14,13 +14,10 @@ from .const import (
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_PANEL_IDLE_TIMEOUT,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
MIN_UPDATE_INTERVAL,
)
from .admin_allowlist import list_admin_ids, set_admin_ids
@@ -59,18 +56,6 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
):
errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid"
# The coordinator refresh interval (Feature 10) is OPTIONAL in raw
# submits: a missing key keeps the current value, so pre-Feature-10
# form payloads (and API callers) are unaffected. Schema-validated
# submits always carry it.
update_interval = user_input.get(CONF_UPDATE_INTERVAL)
if update_interval is not None and (
isinstance(update_interval, bool)
or not isinstance(update_interval, int)
or update_interval < MIN_UPDATE_INTERVAL
):
errors[CONF_UPDATE_INTERVAL] = "invalid"
return errors
@@ -97,9 +82,6 @@ def _build_schema(
vol.Required(
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
): int,
vol.Required(
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
): int,
}
if admin_choices is not None:
# HA's supported multi-select validator: the frontend can
@@ -228,10 +210,6 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
CONF_UPDATE_INTERVAL: user_input.get(
CONF_UPDATE_INTERVAL,
self._current_values()[CONF_UPDATE_INTERVAL],
),
}
if admin_ids_present:
data[CONF_ADMIN_USER_IDS] = admin_ids
@@ -253,7 +231,6 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
):
if key in options:
values[key] = options[key]
@@ -1,90 +0,0 @@
"""Permission gate for NestQuest services (Feature 09).
A wrapper factory, applied to EVERY registered service at registration
time (no handler is exempt), reads ``call.context.user_id``, consults
the :data:`~.service_policy.SERVICE_POLICY` registry and
:func:`~.admin_allowlist.is_admin`, and raises
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
operation called by a non-admin — including a call with NO user
context, which fails closed. ``complete_quest`` (policy ``open``)
proceeds regardless of the allowlist.
Logging (later Feature 09 tasks): every denied admin-only call logs a
warning and every successful one logs a debug line, both carrying the
service, the user id (or ``none``), and a UTC timestamp.
"""
from __future__ import annotations
import datetime
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from homeassistant.exceptions import Unauthorized
from .admin_allowlist import is_admin
from .service_policy import ADMIN_ONLY
LOGGER = logging.getLogger(__name__)
ServiceHandler = Callable[[Any], Awaitable[None]]
def _context_user_id(call: Any) -> str | None:
context = getattr(call, "context", None)
user_id = getattr(context, "user_id", None) if context is not None else None
if isinstance(user_id, str) and user_id:
return user_id
return None
def _stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def permission_gate(
service: str,
policy: str,
database_of: Callable[[Any], Any],
) -> Callable[[ServiceHandler], ServiceHandler]:
"""Wrap ``handler`` with the Feature 09 permission check.
``database_of`` resolves the live database from the call-time
runtime (the same ``find_runtime`` the handler uses), so the gate
never opens its own connection. The verdict is fail-closed:
absent allowlist row, unknown id, no user context — all not admin.
"""
def _wrap(handler: ServiceHandler) -> ServiceHandler:
if policy != ADMIN_ONLY:
return handler
async def _gated(call: Any) -> None:
user_id = _context_user_id(call)
allowed = user_id is not None and await is_admin(
database_of(call), user_id
)
if not allowed:
LOGGER.warning(
"NestQuest denied %s: caller user_id=%s at %s",
service,
user_id if user_id is not None else "none",
_stamp(),
)
raise Unauthorized(
f"User '{user_id if user_id is not None else 'none'}' "
f"is not allowed to call nestquest.{service}"
)
LOGGER.debug(
"NestQuest allowed %s: caller user_id=%s at %s",
service,
user_id,
_stamp(),
)
await handler(call)
return _gated
return _wrap
@@ -1,46 +0,0 @@
"""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)}"
)
-604
View File
@@ -1,604 +0,0 @@
"""Domain-global NestQuest service schemas and handlers (Feature 09).
Registration is domain-scoped and happens once (see
:func:`async_register_services`); handlers resolve a live database at
call time through the injected ``find_runtime`` callable. Permission
checks are Feature 09 later tasks — this module only validates payload
shape and forwards to the Feature 03/06/08 business layers (and the
presence DAOs). ``export_history_csv`` is registered with a schema but
raises until Feature 13. ``regenerate`` is supplied by the caller so
the Feature 07 handler stays the one in ``__init__``.
"""
from __future__ import annotations
import datetime
from collections.abc import Awaitable, Callable
from typing import Any
from zoneinfo import ZoneInfo
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from . import children as children_layer
from . import completion as completion_layer
from . import quest_definitions as quest_definitions_layer
from .const import (
CONF_HORIZON_DAYS,
DEFAULT_HORIZON_DAYS,
DOMAIN,
DOMAIN_SERVICES,
QUEST_WINDOWS,
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,
)
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .materialize import regenerate_for_child
from .permissions import permission_gate
from .presence import PresenceSchedule
from .recurrence import ScheduleRule
from .service_policy import SERVICE_POLICY
FindRuntime = Callable[[HomeAssistant], Any]
ServiceHandler = Callable[[Any], Awaitable[None]]
ACTOR_PANEL = "panel"
ACTOR_USER = "user"
MANAGE_CHILD_CREATE = "create"
MANAGE_CHILD_EDIT = "edit"
MANAGE_CHILD_SET_ACTIVE = "set_active"
MANAGE_CHILD_REORDER = "reorder"
def _strict_int(value: object) -> int:
"""Reject bools and non-ints; voluptuous ``int`` would accept True."""
if isinstance(value, bool) or not isinstance(value, int):
raise vol.Invalid(f"expected int, got {value!r}")
return value
def _strict_bool(value: object) -> bool:
"""Reject ints and other truthy values; require a real bool."""
if type(value) is not bool:
raise vol.Invalid(f"expected bool, got {value!r}")
return value
def _weekday_list(value: object) -> list[int]:
if type(value) is not list:
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
return [_strict_int(item) for item in value]
def _presence_pattern(value: object) -> dict[int, list[int]]:
if not isinstance(value, dict):
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
pattern: dict[int, list[int]] = {}
for week, days in value.items():
if isinstance(week, str):
try:
week_index = int(week)
except ValueError as err:
raise vol.Invalid(
f"pattern week index must be an integer, got {week!r}"
) from err
if str(week_index) != week:
raise vol.Invalid(
f"pattern week index must be an integer, got {week!r}"
)
else:
week_index = _strict_int(week)
pattern[week_index] = _weekday_list(days)
return pattern
def _window_entry(value: object) -> str | tuple[str, str | None]:
if isinstance(value, str):
if value not in QUEST_WINDOWS:
raise vol.Invalid(f"unknown window {value!r}")
return value
if isinstance(value, dict):
parsed = vol.Schema(
{
vol.Required("window"): vol.In(QUEST_WINDOWS),
vol.Optional("due_time"): str,
}
)(value)
due_time = parsed.get("due_time")
if due_time is None:
return parsed["window"]
return (parsed["window"], due_time)
raise vol.Invalid(
f"windows entry must be a window name or object, got {value!r}"
)
def _windows(value: object) -> list[str | tuple[str, str | None]]:
if type(value) is not list:
raise vol.Invalid(f"windows must be a list, got {value!r}")
if not value:
raise vol.Invalid("windows must not be empty")
return [_window_entry(entry) for entry in value]
def _int_list(value: object) -> list[int]:
if type(value) is not list:
raise vol.Invalid(f"expected a list of ints, got {value!r}")
return [_strict_int(item) for item in value]
def _rule_dict(value: object) -> dict:
if not isinstance(value, dict):
raise vol.Invalid(f"rule must be a dict, got {value!r}")
return value
SCHEMA_COMPLETE_QUEST = vol.Schema(
{
vol.Required("instance_id"): _strict_int,
vol.Required("actor"): vol.In([ACTOR_PANEL, ACTOR_USER]),
vol.Optional("actor_child_id"): _strict_int,
vol.Optional("actor_user_id"): str,
}
)
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
{
vol.Required("title"): str,
vol.Required("rule"): _rule_dict,
vol.Required("assignee_child_ids"): vol.All(
_int_list, vol.Length(min=1)
),
vol.Required("windows"): _windows,
vol.Optional("description"): str,
vol.Optional("icon"): str,
}
)
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
{
vol.Required("definition_id"): _strict_int,
vol.Optional("title"): str,
vol.Optional("description"): vol.Any(str, None),
vol.Optional("icon"): vol.Any(str, None),
vol.Optional("rule"): _rule_dict,
vol.Optional("windows"): _windows,
}
)
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
{
vol.Required("definition_id"): _strict_int,
vol.Required("is_active"): _strict_bool,
}
)
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
{
vol.Required("child_id"): _strict_int,
vol.Required("cycle_length_weeks"): _strict_int,
vol.Required("anchor_date"): str,
vol.Required("pattern"): _presence_pattern,
}
)
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
{
vol.Required("child_id"): _strict_int,
vol.Required("start_date"): str,
vol.Required("end_date"): str,
vol.Required("is_present"): _strict_bool,
vol.Optional("note"): str,
}
)
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
{
vol.Required("override_id"): _strict_int,
}
)
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
SCHEMA_MANAGE_CHILD = vol.Schema(
{
vol.Required("action"): vol.In(
[
MANAGE_CHILD_CREATE,
MANAGE_CHILD_EDIT,
MANAGE_CHILD_SET_ACTIVE,
MANAGE_CHILD_REORDER,
]
),
vol.Optional("child_id"): _strict_int,
vol.Optional("display_name"): str,
vol.Optional("colour"): str,
vol.Optional("avatar_ref"): str,
vol.Optional("sort_order"): _strict_int,
vol.Optional("is_active"): _strict_bool,
vol.Optional("ordered_ids"): _int_list,
}
)
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
runtime = find_runtime(hass)
if runtime is None:
raise HomeAssistantError(
"NestQuest has no live database; cannot run this service"
)
return runtime
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
horizon_days = DEFAULT_HORIZON_DAYS
else:
horizon_days = value
return {"today": today, "horizon_days": horizon_days}
def _actor_kwargs(call: Any) -> dict[str, Any]:
actor = call.data["actor"]
if actor == ACTOR_PANEL:
actor_child_id = call.data.get("actor_child_id")
if actor_child_id is None:
raise HomeAssistantError(
"actor_child_id is required when actor is panel"
)
return {
"actor_source": ACTOR_PANEL,
"actor_child_id": actor_child_id,
"actor_user_id": None,
}
context = getattr(call, "context", None)
user_id = getattr(context, "user_id", None) if context is not None else None
if not user_id:
user_id = call.data.get("actor_user_id")
return {
"actor_source": ACTOR_USER,
"actor_user_id": user_id,
"actor_child_id": None,
}
def _with_errors(handler: ServiceHandler) -> ServiceHandler:
async def _wrapped(call: Any) -> None:
try:
await handler(call)
except HomeAssistantError:
raise
except ValueError as err:
raise HomeAssistantError(str(err)) from err
return _wrapped
def _complete_quest(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
await completion_layer.complete_instance(
database,
call.data["instance_id"],
**_actor_kwargs(call),
)
return _with_errors(_handler)
def _uncomplete_quest(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
await completion_layer.uncomplete_instance(
database,
call.data["instance_id"],
**_actor_kwargs(call),
)
return _with_errors(_handler)
def _create_quest_definition(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
data = call.data
await quest_definitions_layer.create_quest_definition(
database,
data["title"],
ScheduleRule.from_dict(data["rule"]),
data["assignee_child_ids"],
data["windows"],
description=data.get("description"),
icon=data.get("icon"),
)
return _with_errors(_handler)
def _update_quest_definition(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
if "title" in data:
kwargs["title"] = data["title"]
if "description" in data:
kwargs["description"] = data["description"]
if "icon" in data:
kwargs["icon"] = data["icon"]
if "rule" in data:
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
if "windows" in data:
kwargs["windows"] = data["windows"]
await quest_definitions_layer.edit_quest_definition(
runtime.database, data["definition_id"], **kwargs
)
return _with_errors(_handler)
def _set_quest_definition_active(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
await quest_definitions_layer.set_quest_definition_active(
runtime.database,
call.data["definition_id"],
call.data["is_active"],
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _set_presence_pattern(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
schedule = PresenceSchedule(
data["child_id"],
data["cycle_length_weeks"],
data["anchor_date"],
data["pattern"],
)
await PresenceSchedulesDao(runtime.database).upsert_by_child(
schedule.child_id,
schedule.cycle_length_weeks,
schedule.anchor_date.isoformat(),
schedule.encode(),
)
await regenerate_for_child(
runtime.database,
schedule.child_id,
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _create_presence_override(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
await PresenceOverridesDao(runtime.database).create(
data["child_id"],
data["start_date"],
data["end_date"],
data["is_present"],
note=data.get("note"),
)
await regenerate_for_child(
runtime.database,
data["child_id"],
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _delete_presence_override(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
dao = PresenceOverridesDao(runtime.database)
override_id = call.data["override_id"]
override = await dao.get(override_id)
if override is None:
raise HomeAssistantError(
f"override_id: presence override {override_id} does not exist"
)
await dao.delete(override_id)
await regenerate_for_child(
runtime.database,
override.child_id,
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
async def _export_history_csv(_call: Any) -> None:
raise HomeAssistantError(
"CSV history export is not implemented until Feature 13"
)
def _manage_child(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
data = call.data
action = data["action"]
if action == MANAGE_CHILD_CREATE:
display_name = data.get("display_name")
if display_name is None:
raise HomeAssistantError(
"display_name is required when action is create"
)
kwargs: dict[str, Any] = {}
if "colour" in data:
kwargs["colour"] = data["colour"]
if "avatar_ref" in data:
kwargs["avatar_ref"] = data["avatar_ref"]
if "sort_order" in data:
kwargs["sort_order"] = data["sort_order"]
await children_layer.create_child(
database, display_name, **kwargs
)
return
if action == MANAGE_CHILD_EDIT:
child_id = data.get("child_id")
if child_id is None:
raise HomeAssistantError(
"child_id is required when action is edit"
)
kwargs = {}
if "display_name" in data:
kwargs["display_name"] = data["display_name"]
if "colour" in data:
kwargs["colour"] = data["colour"]
if "avatar_ref" in data:
kwargs["avatar_ref"] = data["avatar_ref"]
if "sort_order" in data:
kwargs["sort_order"] = data["sort_order"]
await children_layer.edit_child(database, child_id, **kwargs)
return
if action == MANAGE_CHILD_SET_ACTIVE:
child_id = data.get("child_id")
is_active = data.get("is_active")
if child_id is None:
raise HomeAssistantError(
"child_id is required when action is set_active"
)
if is_active is None:
raise HomeAssistantError(
"is_active is required when action is set_active"
)
await children_layer.set_child_active(
database, child_id, is_active
)
return
ordered_ids = data.get("ordered_ids")
if ordered_ids is None:
raise HomeAssistantError(
"ordered_ids is required when action is reorder"
)
await children_layer.reorder_children(database, ordered_ids)
return _with_errors(_handler)
def async_register_services(
hass: HomeAssistant,
*,
find_runtime: FindRuntime,
regenerate_handler: ServiceHandler,
) -> None:
"""Register every canonical service once at domain scope.
Every service — regenerate included — passes through the Feature
09 permission gate before its handler runs; the gate reads
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
registered exempt from the policy.
"""
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
return
gate = permission_gate
specs: list[tuple[str, ServiceHandler, object]] = [
(SERVICE_REGENERATE, regenerate_handler, None),
(
SERVICE_COMPLETE_QUEST,
_complete_quest(hass, find_runtime),
SCHEMA_COMPLETE_QUEST,
),
(
SERVICE_UNCOMPLETE_QUEST,
_uncomplete_quest(hass, find_runtime),
SCHEMA_UNCOMPLETE_QUEST,
),
(
SERVICE_CREATE_QUEST_DEFINITION,
_create_quest_definition(hass, find_runtime),
SCHEMA_CREATE_QUEST_DEFINITION,
),
(
SERVICE_UPDATE_QUEST_DEFINITION,
_update_quest_definition(hass, find_runtime),
SCHEMA_UPDATE_QUEST_DEFINITION,
),
(
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
_set_quest_definition_active(hass, find_runtime),
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
),
(
SERVICE_SET_PRESENCE_PATTERN,
_set_presence_pattern(hass, find_runtime),
SCHEMA_SET_PRESENCE_PATTERN,
),
(
SERVICE_CREATE_PRESENCE_OVERRIDE,
_create_presence_override(hass, find_runtime),
SCHEMA_CREATE_PRESENCE_OVERRIDE,
),
(
SERVICE_DELETE_PRESENCE_OVERRIDE,
_delete_presence_override(hass, find_runtime),
SCHEMA_DELETE_PRESENCE_OVERRIDE,
),
(
SERVICE_EXPORT_HISTORY_CSV,
_export_history_csv,
SCHEMA_EXPORT_HISTORY_CSV,
),
(
SERVICE_MANAGE_CHILD,
_manage_child(hass, find_runtime),
SCHEMA_MANAGE_CHILD,
),
]
def _database_of(_call: Any) -> Any:
return _require_runtime(hass, find_runtime).database
for service, handler, schema in specs:
gated = gate(
service, SERVICE_POLICY[service], _database_of
)(handler)
hass.services.async_register(DOMAIN, service, gated, schema=schema)
def async_deregister_services(hass: HomeAssistant) -> None:
"""Remove every canonical service when the last entry unloads."""
for service in DOMAIN_SERVICES:
if hass.services.has_service(DOMAIN, service):
hass.services.async_remove(DOMAIN, service)
+1 -315
View File
@@ -9,318 +9,4 @@ regenerate:
horizon, using the same idempotent materialization as the daily rollover
and the startup backfill. Safe to call at any time: a re-run refreshes the
same rows instead of duplicating them, and completed instances are never
touched.
complete_quest:
name: Complete quest
description: Mark a quest instance as completed.
fields:
instance_id:
name: Instance ID
description: The quest instance to complete.
required: true
selector:
number:
mode: box
actor:
name: Actor
description: Who completed the quest (panel or user).
required: true
selector:
select:
options:
- panel
- user
actor_child_id:
name: Actor child ID
description: The tapped child profile. Required when actor is panel.
required: false
selector:
number:
mode: box
actor_user_id:
name: Actor user ID
description: Home Assistant user id when actor is user and no call context is present.
required: false
selector:
text:
uncomplete_quest:
name: Uncomplete quest
description: Undo a quest instance completion.
fields:
instance_id:
name: Instance ID
description: The quest instance to uncomplete.
required: true
selector:
number:
mode: box
actor:
name: Actor
description: Who is undoing the completion (panel or user).
required: true
selector:
select:
options:
- panel
- user
actor_child_id:
name: Actor child ID
description: The tapped child profile. Required when actor is panel.
required: false
selector:
number:
mode: box
actor_user_id:
name: Actor user ID
description: Home Assistant user id when actor is user and no call context is present.
required: false
selector:
text:
create_quest_definition:
name: Create quest definition
description: Create a quest definition with its schedule rule, assignees and windows.
fields:
title:
name: Title
description: Display title for the quest.
required: true
selector:
text:
rule:
name: Rule
description: Recurrence rule as a mapping (rule_type plus shape fields).
required: true
selector:
object:
assignee_child_ids:
name: Assignee child IDs
description: Child ids this definition is assigned to.
required: true
selector:
object:
windows:
name: Windows
description: Day windows (morning, afternoon, evening), optionally with a due time.
required: true
selector:
object:
description:
name: Description
description: Optional longer description.
required: false
selector:
text:
icon:
name: Icon
description: Optional icon name.
required: false
selector:
text:
update_quest_definition:
name: Update quest definition
description: Edit a quest definition's title, description, icon, rule or windows.
fields:
definition_id:
name: Definition ID
description: The quest definition to update.
required: true
selector:
number:
mode: box
title:
name: Title
description: Replacement display title.
required: false
selector:
text:
description:
name: Description
description: Replacement description, or null to clear.
required: false
selector:
text:
icon:
name: Icon
description: Replacement icon, or null to clear.
required: false
selector:
text:
rule:
name: Rule
description: Replacement recurrence rule as a mapping.
required: false
selector:
object:
windows:
name: Windows
description: Replacement day windows.
required: false
selector:
object:
set_quest_definition_active:
name: Set quest definition active
description: Deactivate or reactivate a quest definition.
fields:
definition_id:
name: Definition ID
description: The quest definition to update.
required: true
selector:
number:
mode: box
is_active:
name: Active
description: Whether the definition should generate future instances.
required: true
selector:
boolean:
set_presence_pattern:
name: Set presence pattern
description: Upsert a child's repeating presence schedule.
fields:
child_id:
name: Child ID
description: The child whose schedule is being set.
required: true
selector:
number:
mode: box
cycle_length_weeks:
name: Cycle length weeks
description: Number of weeks in the repeating cycle (1 to 4).
required: true
selector:
number:
mode: box
min: 1
max: 4
anchor_date:
name: Anchor date
description: Cycle anchor as a YYYY-MM-DD date.
required: true
selector:
date:
pattern:
name: Pattern
description: Mapping of week index to present weekday ints (Monday is 0).
required: true
selector:
object:
create_presence_override:
name: Create presence override
description: Create a date-range presence override for a child.
fields:
child_id:
name: Child ID
description: The child the override applies to.
required: true
selector:
number:
mode: box
start_date:
name: Start date
description: Inclusive start date (YYYY-MM-DD).
required: true
selector:
date:
end_date:
name: End date
description: Inclusive end date (YYYY-MM-DD).
required: true
selector:
date:
is_present:
name: Present
description: Whether the child is present for this range.
required: true
selector:
boolean:
note:
name: Note
description: Optional note stored with the override.
required: false
selector:
text:
delete_presence_override:
name: Delete presence override
description: Delete a presence override by id.
fields:
override_id:
name: Override ID
description: The override to delete.
required: true
selector:
number:
mode: box
export_history_csv:
name: Export history CSV
description: Export completion history as CSV. Not implemented until Feature 13.
manage_child:
name: Manage child
description: Create, edit, activate or reorder child profiles.
fields:
action:
name: Action
description: create, edit, set_active, or reorder.
required: true
selector:
select:
options:
- create
- edit
- set_active
- reorder
child_id:
name: Child ID
description: Required for edit and set_active.
required: false
selector:
number:
mode: box
display_name:
name: Display name
description: Required for create; optional for edit.
required: false
selector:
text:
colour:
name: Colour
description: Optional colour string.
required: false
selector:
text:
avatar_ref:
name: Avatar
description: Optional avatar reference.
required: false
selector:
text:
sort_order:
name: Sort order
description: Optional panel sort order.
required: false
selector:
number:
mode: box
is_active:
name: Active
description: Required for set_active.
required: false
selector:
boolean:
ordered_ids:
name: Ordered IDs
description: Complete permutation of child ids. Required for reorder.
required: false
selector:
object:
touched.
+6 -93
View File
@@ -45,7 +45,6 @@ _HA_MODULES = (
"homeassistant.helpers",
"homeassistant.helpers.config_validation",
"homeassistant.helpers.event",
"homeassistant.helpers.update_coordinator",
)
def _find_spec(name: str):
@@ -86,7 +85,6 @@ for _parent, _child in (
("homeassistant", "helpers"),
("homeassistant.helpers", "config_validation"),
("homeassistant.helpers", "event"),
("homeassistant.helpers", "update_coordinator"),
):
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
@@ -179,11 +177,7 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
_data_entry_flow_mock.FlowResult = dict
class HomeAssistantError(Exception):
"""Stand-in mirroring homeassistant.exceptions.HomeAssistantError."""
class ConfigEntryNotReady(HomeAssistantError):
class ConfigEntryNotReady(Exception):
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
HA retries setup when this is raised; the tests assert the corrupt
@@ -191,73 +185,7 @@ class ConfigEntryNotReady(HomeAssistantError):
"""
class Unauthorized(HomeAssistantError):
"""Stand-in mirroring homeassistant.exceptions.Unauthorized."""
_exceptions_mock.HomeAssistantError = HomeAssistantError
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
_exceptions_mock.Unauthorized = Unauthorized
class DataUpdateCoordinator:
"""Functional stand-in for HA's DataUpdateCoordinator.
Mirrors the surface NestQuest's coordinator subclass uses:
``__init__(hass, logger, name=..., update_interval=...)``,
``async_add_listener``/``async_update_listeners`` for entity
subscription, ``async_refresh``/``async_config_entry_first_refresh``
driving the subclass's ``_async_update_data``, ``last_update_success``,
and ``data``. Interval scheduling is NOT simulated (no real clock
ticks in tests) — tests drive refreshes directly.
"""
def __init__(self, hass, logger, *, name=None, update_interval=None):
self.hass = hass
self.logger = logger
self.name = name
self.update_interval = update_interval
self.data = None
self.last_update_success = False
self._listeners = []
def async_add_listener(self, update_callback, context=None):
"""Subscribe; returns an unsubscribe callable like HA's."""
entry = (update_callback, context)
self._listeners.append(entry)
def _remove():
if entry in self._listeners:
self._listeners.remove(entry)
return _remove
def async_update_listeners(self):
"""Notify every listener (entities schedule their writes)."""
for callback, _context in list(self._listeners):
callback()
async def async_refresh(self):
"""Run one update pass and notify listeners."""
try:
self.data = await self._async_update_data()
self.last_update_success = True
except BaseException:
self.last_update_success = False
raise
self.async_update_listeners()
async def async_config_entry_first_refresh(self):
"""First refresh at config-entry setup (alias of refresh)."""
await self.async_refresh()
async def async_shutdown(self):
"""Release listeners on teardown."""
self._listeners.clear()
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
import voluptuous as vol
@@ -397,7 +325,7 @@ class ServiceRegistry:
self._services: dict[tuple[str, str], object] = {}
def async_register(self, domain, service, func, schema=None, supports_response=None):
self._services[(domain, service)] = (func, schema)
self._services[(domain, service)] = func
return None
def has_service(self, domain, service):
@@ -407,26 +335,11 @@ class ServiceRegistry:
self._services.pop((domain, service), None)
return None
async def call(self, domain, service, data=None, context=None):
registered = self._services.get((domain, service))
if registered is None:
async def call(self, domain, service, data=None):
func = self._services.get((domain, service))
if func is None:
raise KeyError(f"service {domain}.{service} not registered")
func, schema = registered
payload = dict(data or {})
if schema is not None:
payload = schema(payload)
if context is None:
context_obj = SimpleNamespace(user_id=None)
elif isinstance(context, dict):
context_obj = SimpleNamespace(**context)
else:
context_obj = context
call = SimpleNamespace(
domain=domain,
service=service,
data=payload,
context=context_obj,
)
call = SimpleNamespace(domain=domain, service=service, data=data or {})
result = func(call)
if inspect.isawaitable(result):
await result
+3 -1007
View File
File diff suppressed because it is too large Load Diff
-281
View File
@@ -1,281 +0,0 @@
"""Tests for the shared NestQuest coordinator (Feature 10, task 1)."""
from __future__ import annotations
import datetime
import pytest
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.children import (
create_child,
list_children,
set_child_active,
)
from custom_components.nestquest.completion import complete_instance
from custom_components.nestquest.const import (
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
DEFAULT_HORIZON_DAYS,
DOMAIN,
)
from custom_components.nestquest.coordinator import NestQuestCoordinator
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
ADMIN_CTX = {"user_id": "admin-1"}
def _today_iso() -> str:
return datetime.date.today().isoformat()
async def _seed_day(database, *, completed: bool = False) -> None:
"""Two active children + one inactive, one daily definition each."""
await create_child(database, "Ada")
await create_child(database, "Bo")
inactive = await create_child(database, "Ghost")
await set_child_active(database, inactive.id, False)
today = _today_iso()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict({"rule_type": "daily", "start_date": today}),
[child.id for child in await list_children(database, active_only=True)],
["morning"],
)
end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
await materialize(database, today, end, today=datetime.date.today())
async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
assert coordinator is not None
database = entry.runtime_data.database
await _seed_day(database)
snapshot = await coordinator._async_update_data()
assert snapshot.today_iso == _today_iso()
assert len(snapshot.children) == 2, "inactive child must be absent"
by_name = {child.child_name: child for child in snapshot.children}
ada = by_name["Ada"]
assert ada.due_today == 1
assert ada.completed_today == 0
assert ada.remaining_today == 1
assert ada.completion_pct == 0
instance = ada.instances[0]
assert instance.state == "open"
assert instance.overdue is False
assert instance.title == "Brush teeth"
assert instance.window == "morning"
assert ada.present is True, "no schedule means present every day"
assert snapshot.cycle_day == 0, "no schedule anywhere"
async def test_coordinator_reflects_completion_and_uncompletion(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await _seed_day(database)
ada = (
await coordinator._async_update_data()
).children[0]
instance_id = ada.instances[0].instance_id
await complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=ada.child_id,
)
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.completed_today == 1
assert child.remaining_today == 0
assert child.completion_pct == 100
view = child.instances[0]
assert view.state == "done"
assert view.completed_at is not None
assert view.was_on_time is not None
from custom_components.nestquest.completion import uncomplete_instance
await uncomplete_instance(
database, instance_id, actor_source="user", actor_user_id="admin-1"
)
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.completed_today == 0
assert child.remaining_today == 1
assert child.instances[0].state == "open"
async def test_coordinator_zero_quests_reports_full_percentage(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
await create_child(entry.runtime_data.database, "Ada")
snapshot = await coordinator._async_update_data()
child = snapshot.children[0]
assert child.due_today == 0
assert child.completion_pct == 100, "zero quests is a perfect day"
assert child.instances == ()
async def test_coordinator_cycle_day_from_first_scheduled_child(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
# A 2-week cycle anchored 3 days ago: today is day 4.
anchor = today - datetime.timedelta(days=3)
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 2, anchor.isoformat(), "0,1,2,3,4,5,6|"
)
snapshot = await coordinator._async_update_data()
assert snapshot.cycle_day == 4
async def test_coordinator_presence_resolves_schedule_and_override(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
await create_child(database, "Ada")
ada = (await list_children(database))[0]
today = datetime.date.today()
# Scheduled absent today (empty weekday set for this cycle week).
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), ""
)
snapshot = await coordinator._async_update_data()
assert snapshot.children[0].present is False
# An override beats the pattern.
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
await PresenceOverridesDao(database).create(
ada.id, today.isoformat(), today.isoformat(), True
)
snapshot = await coordinator._async_update_data()
assert snapshot.children[0].present is True
async def test_coordinator_derives_missed_for_past_due_open(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
snapshot = await coordinator._async_update_data()
# No children: empty but valid snapshot.
assert snapshot.children == ()
async def test_setup_creates_coordinator_with_configured_interval(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(
make_entry(options={CONF_UPDATE_INTERVAL: 60}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
assert isinstance(coordinator, NestQuestCoordinator)
assert coordinator.update_interval == datetime.timedelta(seconds=60)
assert coordinator.data is not None, "first refresh ran at setup"
async def test_setup_defaults_to_five_minute_interval(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
assert coordinator.update_interval == datetime.timedelta(
seconds=DEFAULT_UPDATE_INTERVAL
)
async def test_unload_shuts_coordinator_down(hass, make_entry) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
torn_down = []
async def _shutdown():
torn_down.append(coordinator)
coordinator.async_shutdown = _shutdown
assert await async_unload_entry(hass, entry) is True
assert torn_down == [coordinator]
async def test_coordinator_notifies_listeners_on_refresh(
hass, make_entry
) -> None:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
called = []
coordinator.async_add_listener(lambda: called.append("entity"))
await coordinator.async_refresh()
assert called == ["entity"]
assert coordinator.last_update_success is True
async def test_failed_first_refresh_shuts_coordinator_down(
hass, make_entry, monkeypatch
) -> None:
"""A setup failure after coordinator creation must not leak its
listeners against the database the except path closes."""
from custom_components.nestquest import async_setup_entry
import custom_components.nestquest.coordinator as coordinator_module
shutdown_calls = []
async def _record_shutdown(self):
shutdown_calls.append(self.entry_id)
monkeypatch.setattr(
coordinator_module.NestQuestCoordinator,
"async_shutdown",
_record_shutdown,
)
async def _boom(self):
raise RuntimeError("refresh failed")
monkeypatch.setattr(
coordinator_module.NestQuestCoordinator,
"_async_update_data",
_boom,
)
entry = wire_entry_to_registry(make_entry(), hass.registry)
with pytest.raises(RuntimeError, match="refresh failed"):
await async_setup_entry(hass, entry)
assert shutdown_calls == [entry.entry_id]
assert getattr(entry, "runtime_data", None) is None
-14
View File
@@ -455,20 +455,6 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
assert _with_db(tmp_path, "override-list-unknown.db")(_body) == []
def test_override_get_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
record = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True, note="camp"
)
found = await overrides.get(record.id)
assert found == record
assert await overrides.get(999) is None
return found
found = _with_db(tmp_path, "override-get.db")(_body)
assert found.note == "camp"
def test_override_delete_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
record = await overrides.create(
+8 -21
View File
@@ -9,7 +9,6 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
DEFAULT_HORIZON_DAYS,
@@ -17,13 +16,6 @@ from custom_components.nestquest.const import (
DOMAIN as DOMAIN_CONST,
)
ADMIN_ID = "admin-1"
ADMIN_CTX = {"user_id": ADMIN_ID}
def _admin_data():
return {CONF_ADMIN_USER_IDS: [ADMIN_ID]}
def _wire(entry, registry):
return wire_entry_to_registry(entry, registry)
@@ -554,7 +546,7 @@ async def test_regenerate_service_registered_and_materializes(
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(make_entry(data=_admin_data()), hass.registry)
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
@@ -563,7 +555,7 @@ async def test_regenerate_service_registered_and_materializes(
database = entry.runtime_data.database
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
@@ -582,15 +574,14 @@ async def test_regenerate_service_uses_configured_horizon_days(
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
hass.registry,
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
today = _local_today(hass)
end = (today + datetime.timedelta(days=5)).isoformat()
@@ -631,12 +622,8 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry_a = _wire(
make_entry(entry_id="entry_a", data=_admin_data()), hass.registry
)
entry_b = _wire(
make_entry(entry_id="entry_b", data=_admin_data()), hass.registry
)
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
assert await async_setup_entry(hass, entry_a) is True
assert await async_setup_entry(hass, entry_b) is True
@@ -647,7 +634,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
child_id = await _seed_daily_child_and_definition(
database_b, _local_today(hass)
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
records = await QuestInstancesDao(database_b).list_by_date_range(
@@ -660,7 +647,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
# It still works for the remaining entry.
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
# Unloading the final entry removes the domain-global service.
assert await async_unload_entry(hass, entry_b) is True
+5 -19
View File
@@ -18,8 +18,6 @@ from custom_components.nestquest.const import (
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_PANEL_IDLE_TIMEOUT,
@@ -131,10 +129,7 @@ def test_options_flow_submits_through_2024_6_surface() -> None:
result = _run(handler.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["title"] == "NestQuest"
assert result["data"] == {
**VALID_INPUT,
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
}
assert result["data"] == VALID_INPUT
def test_options_flow_shows_form_initially() -> None:
@@ -213,7 +208,6 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
CONF_HORIZON_DAYS: 21,
CONF_DAY_ROLLOVER_TIME: "05:45",
CONF_PANEL_IDLE_TIMEOUT: 120,
CONF_UPDATE_INTERVAL: 90,
}
)
result = _run(_make_flow(entry).async_step_init(None))
@@ -221,7 +215,6 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
CONF_HORIZON_DAYS: 21,
CONF_DAY_ROLLOVER_TIME: "05:45",
CONF_PANEL_IDLE_TIMEOUT: 120,
CONF_UPDATE_INTERVAL: 90,
CONF_ADMIN_USER_IDS: [],
}
@@ -289,8 +282,8 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
def test_options_flow_creates_entry_with_four_keys() -> None:
"""A valid submit creates an entry with exactly the four validated keys."""
def test_options_flow_creates_entry_with_three_keys() -> None:
"""A valid submit creates an entry with exactly the three validated keys."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
@@ -299,12 +292,8 @@ def test_options_flow_creates_entry_with_four_keys() -> None:
CONF_HORIZON_DAYS,
CONF_DAY_ROLLOVER_TIME,
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
}
assert result["data"] == {
**VALID_INPUT,
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
}
assert result["data"] == VALID_INPUT
def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None:
@@ -327,10 +316,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
_run(registry.dispatch_options_update(entry))
assert hass.config_entries.async_reload.call_count == 1
assert registry.reloaded == [entry.entry_id]
assert entry.options == {
**VALID_INPUT,
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
}
assert entry.options == VALID_INPUT
def test_options_flow_validation_never_raises() -> None:
-238
View File
@@ -1,238 +0,0 @@
"""Feature 09 permission test matrix across every registered service."""
from __future__ import annotations
import datetime
import pytest
from homeassistant.exceptions import Unauthorized
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DEFAULT_HORIZON_DAYS,
DOMAIN,
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,
)
from custom_components.nestquest.service_policy import (
ADMIN_ONLY,
OPEN,
SERVICE_POLICY,
)
ADMIN_ID = "admin-1"
ADMIN_CTX = {"user_id": ADMIN_ID}
NON_ADMIN_ID = "non-admin"
NON_ADMIN_CTX = {"user_id": NON_ADMIN_ID}
KIOSK_ID = "kiosk-panel"
KIOSK_CTX = {"user_id": KIOSK_ID}
def test_every_registered_service_is_in_the_operation_registry() -> None:
"""The registry is total: a service outside it could never be gated."""
missing = [
service for service in DOMAIN_SERVICES if service not in SERVICE_POLICY
]
extra = [
service for service in SERVICE_POLICY if service not in DOMAIN_SERVICES
]
assert not missing, f"registered but ungated: {missing}"
assert not extra, f"gated but never registered: {extra}"
assert set(SERVICE_POLICY.values()) == {OPEN, ADMIN_ONLY}
async def _setup(hass, make_entry, *, admin=True):
data = {CONF_ADMIN_USER_IDS: [ADMIN_ID]} if admin else {}
entry = wire_entry_to_registry(make_entry(data=data), hass.registry)
assert await async_setup_entry(hass, entry) is True
return entry
async def _seed(hass, entry) -> dict:
"""Seed one child, one definition, and materialized instances."""
from custom_components.nestquest.children import list_children
from custom_components.nestquest.dao_instances import QuestInstancesDao
database = entry.runtime_data.database
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [(await list_children(database))[0].id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
child = (await list_children(database))[0]
today = datetime.date.today().isoformat()
end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
instances = await QuestInstancesDao(database).list_by_date_range(
child.id, today, end
)
assert instances
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
return {
"child_id": child.id,
"instance_id": instances[0].id,
"definition_id": instances[0].definition_id,
"override": await PresenceOverridesDao(database).create(
child.id, "2027-01-01", "2027-01-02", False
),
"today": today,
"database": database,
}
def _admin_success_payload(service: str, ctx: dict) -> dict | None:
"""A schema-valid, end-to-end-successful payload per admin service."""
if service == SERVICE_UNCOMPLETE_QUEST:
return {"instance_id": ctx["instance_id"], "actor": "user"}
if service == SERVICE_CREATE_QUEST_DEFINITION:
return {
"title": "Second quest",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [ctx["child_id"]],
"windows": ["afternoon"],
}
if service == SERVICE_UPDATE_QUEST_DEFINITION:
return {"definition_id": ctx["definition_id"], "title": "Brush teeth 2"}
if service == SERVICE_SET_QUEST_DEFINITION_ACTIVE:
return {"definition_id": ctx["definition_id"], "is_active": False}
if service == SERVICE_SET_PRESENCE_PATTERN:
return {
"child_id": ctx["child_id"],
"cycle_length_weeks": 1,
"anchor_date": ctx["today"],
"pattern": {0: [0, 1, 2, 3, 4, 5, 6]},
}
if service == SERVICE_CREATE_PRESENCE_OVERRIDE:
return {
"child_id": ctx["child_id"],
"start_date": "2027-03-01",
"end_date": "2027-03-02",
"is_present": False,
}
if service == SERVICE_DELETE_PRESENCE_OVERRIDE:
return {"override_id": ctx["override"].id}
if service == SERVICE_EXPORT_HISTORY_CSV:
return None # raises HomeAssistantError (Feature 13) — not Unauthorized
if service == SERVICE_MANAGE_CHILD:
return {"action": "create", "display_name": "Bo"}
if service == SERVICE_REGENERATE:
return {}
raise AssertionError(f"unexpected service {service}")
_ADMIN_ONLY = [
service for service, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
]
@pytest.mark.parametrize("service", _ADMIN_ONLY)
async def test_admin_succeeds_on_every_admin_only_service(
hass, make_entry, service
) -> None:
from homeassistant.exceptions import HomeAssistantError
entry = await _setup(hass, make_entry)
ctx = await _seed(hass, entry)
if service == SERVICE_EXPORT_HISTORY_CSV:
# The gate passes; the Feature 13 stub raises its "not
# implemented" HomeAssistantError — NOT Unauthorized.
with pytest.raises(HomeAssistantError, match="Feature 13"):
await hass.services.call(
DOMAIN, service, None, context=ADMIN_CTX
)
return
await hass.services.call(
DOMAIN, service, _admin_success_payload(service, ctx), context=ADMIN_CTX
)
@pytest.mark.parametrize("service", _ADMIN_ONLY)
async def test_non_admin_refused_on_every_admin_only_service(
hass, make_entry, service
) -> None:
entry = await _setup(hass, make_entry)
ctx = await _seed(hass, entry)
if service != SERVICE_EXPORT_HISTORY_CSV:
await hass.services.call(
DOMAIN,
service,
_admin_success_payload(service, ctx),
context=ADMIN_CTX,
)
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
service,
_admin_success_payload(service, ctx),
context=NON_ADMIN_CTX,
)
async def test_non_admin_allowed_on_complete_quest(hass, make_entry) -> None:
from custom_components.nestquest.completion import instance_state
entry = await _setup(hass, make_entry)
ctx = await _seed(hass, entry)
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": ctx["instance_id"],
"actor": "panel",
"actor_child_id": ctx["child_id"],
},
context=NON_ADMIN_CTX,
)
assert (
await instance_state(ctx["database"], ctx["instance_id"]) == "done"
)
async def test_kiosk_refused_on_uncomplete_and_regenerate(
hass, make_entry
) -> None:
entry = await _setup(hass, make_entry)
ctx = await _seed(hass, entry)
from custom_components.nestquest.admin_allowlist import list_admin_ids
assert KIOSK_ID not in await list_admin_ids(ctx["database"])
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_UNCOMPLETE_QUEST,
{"instance_id": ctx["instance_id"], "actor": "user"},
context=KIOSK_CTX,
)
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN, SERVICE_REGENERATE, context=KIOSK_CTX
)
-122
View File
@@ -1,122 +0,0 @@
"""Tests for the Feature 09 permission gate's audit logging."""
from __future__ import annotations
import logging
import re
import pytest
from homeassistant.exceptions import Unauthorized
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DOMAIN,
SERVICE_MANAGE_CHILD,
)
ADMIN_ID = "admin-1"
ADMIN_CTX = {"user_id": ADMIN_ID}
_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
async def _set_up(hass, make_entry):
entry = wire_entry_to_registry(
make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
return entry
async def test_denied_call_logs_warning_with_fields(
hass, make_entry, caplog
) -> None:
entry = await _set_up(hass, make_entry)
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context={"user_id": "kiosk-panel"},
)
denials = [
record
for record in caplog.records
if record.levelno == logging.WARNING
and "denied" in record.getMessage()
]
assert len(denials) == 1
message = denials[0].getMessage()
assert SERVICE_MANAGE_CHILD in message
assert "kiosk-panel" in message
assert _TIMESTAMP.search(message), message
async def test_denied_no_context_logs_user_none(
hass, make_entry, caplog
) -> None:
entry = await _set_up(hass, make_entry)
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
)
denials = [
record
for record in caplog.records
if record.levelno == logging.WARNING
and "denied" in record.getMessage()
]
assert len(denials) == 1
message = denials[0].getMessage()
assert "none" in message
assert _TIMESTAMP.search(message), message
async def test_successful_admin_call_logs_debug_with_fields(
hass, make_entry, caplog
) -> None:
caplog.set_level(logging.DEBUG, logger="custom_components.nestquest")
entry = await _set_up(hass, make_entry)
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
allowed = [
record
for record in caplog.records
if record.levelno == logging.DEBUG
and "allowed" in record.getMessage()
]
assert len(allowed) == 1
message = allowed[0].getMessage()
assert SERVICE_MANAGE_CHILD in message
assert ADMIN_ID in message
assert _TIMESTAMP.search(message), message
async def test_denied_log_carries_no_personal_data_beyond_user_id(
hass, make_entry, caplog
) -> None:
"""The log line names the caller's user id only: the payload's
display name must not leak into the audit trail."""
entry = await _set_up(hass, make_entry)
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context={"user_id": "kiosk-panel"},
)
denials = [
record.getMessage()
for record in caplog.records
if record.levelno == logging.WARNING and "denied" in record.getMessage()
]
assert denials, "expected a denial log line"
assert "Ada" not in denials[0]
assert "display_name" not in denials[0]
-32
View File
@@ -1,32 +0,0 @@
"""Tests for the Feature 09 admin-only versus open service registry."""
from __future__ import annotations
from custom_components.nestquest.const import (
DOMAIN_SERVICES,
SERVICE_COMPLETE_QUEST,
)
from custom_components.nestquest.service_policy import (
ADMIN_ONLY,
OPEN,
SERVICE_POLICY,
)
def test_policy_covers_exactly_the_registered_services() -> None:
assert frozenset(SERVICE_POLICY) == frozenset(DOMAIN_SERVICES)
def test_only_complete_quest_is_open() -> None:
open_services = {
name for name, access in SERVICE_POLICY.items() if access == OPEN
}
assert open_services == {SERVICE_COMPLETE_QUEST}
assert all(
access == ADMIN_ONLY
for name, access in SERVICE_POLICY.items()
if name != SERVICE_COMPLETE_QUEST
)
def test_policy_values_are_only_open_or_admin_only() -> None:
assert set(SERVICE_POLICY.values()) <= {OPEN, ADMIN_ONLY}
+8 -560
View File
@@ -1,33 +1,9 @@
"""Tests for NestQuest service declarations and domain-global handlers."""
"""Tests for the NestQuest services.yaml declaration file."""
from __future__ import annotations
import re
from pathlib import Path
import pytest
import voluptuous as vol
from homeassistant.exceptions import HomeAssistantError, Unauthorized
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DOMAIN,
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,
)
from custom_components.nestquest.service_policy import ADMIN_ONLY, SERVICE_POLICY
SERVICES_PATH = (
Path(__file__).parent.parent
/ "custom_components"
@@ -36,47 +12,10 @@ SERVICES_PATH = (
)
def _wire(entry, registry):
return wire_entry_to_registry(entry, registry)
ADMIN_ID = "admin-1"
ADMIN_CTX = {"user_id": ADMIN_ID}
def _make_admin_entry(make_entry):
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
return make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]})
def _service_section(text: str, name: str) -> str:
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
assert match is not None, f"services.yaml must declare {name!r}"
section = text[match.end() :]
next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE)
if next_service is not None:
section = section[: next_service.start()]
return section
def test_services_yaml_exists() -> None:
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
def test_services_yaml_declares_every_canonical_service() -> None:
text = SERVICES_PATH.read_text(encoding="utf-8")
declared = re.findall(r"^(?![#\s])(\w+):\s*$", text, re.MULTILINE)
assert declared == list(DOMAIN_SERVICES) or set(declared) == set(
DOMAIN_SERVICES
)
for name in DOMAIN_SERVICES:
section = _service_section(text, name)
assert re.search(r"description:", section), (
f"{name} must carry a description"
)
def test_services_yaml_declares_regenerate() -> None:
text = SERVICES_PATH.read_text(encoding="utf-8")
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
@@ -88,506 +27,15 @@ def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
"""The regenerate service is documented with a clear description and no
required fields (there is no ``fields`` block under it)."""
text = SERVICES_PATH.read_text(encoding="utf-8")
section = _service_section(text, "regenerate")
match = re.search(r"^regenerate:\s*$", text, re.MULTILINE)
assert match is not None
section = text[match.end():]
next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE)
if next_service is not None:
section = section[: next_service.start()]
assert re.search(r"description:", section), (
"regenerate must carry a description"
)
assert "fields:" not in section, (
"regenerate declares no required fields"
)
def test_services_yaml_other_services_declare_fields() -> None:
text = SERVICES_PATH.read_text(encoding="utf-8")
for name in DOMAIN_SERVICES:
if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV):
continue
section = _service_section(text, name)
assert "fields:" in section, f"{name} must declare fields"
async def test_all_canonical_services_registered_after_setup(
hass, make_entry
) -> None:
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
for service in DOMAIN_SERVICES:
assert hass.services.has_service(DOMAIN, service), service
async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert await async_unload_entry(hass, entry) is True
for service in DOMAIN_SERVICES:
assert not hass.services.has_service(DOMAIN, service), service
async def test_unload_one_of_two_entries_keeps_services(
hass, make_entry
) -> None:
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
assert await async_setup_entry(hass, entry_a) is True
assert await async_setup_entry(hass, entry_b) is True
assert await async_unload_entry(hass, entry_a) is True
for service in DOMAIN_SERVICES:
assert hass.services.has_service(DOMAIN, service), service
assert await async_unload_entry(hass, entry_b) is True
for service in DOMAIN_SERVICES:
assert not hass.services.has_service(DOMAIN, service), service
async def test_manage_child_create_persists_child(hass, make_entry) -> None:
from custom_components.nestquest.children import list_children
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
children = await list_children(entry.runtime_data.database)
assert [child.display_name for child in children] == ["Ada"]
async def test_create_and_set_quest_definition_active(
hass, make_entry
) -> None:
from custom_components.nestquest.children import list_children
from custom_components.nestquest.quest_definitions import (
list_active_definitions,
)
from custom_components.nestquest.dao_rules import QuestDefinitionsDao
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child.id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
active = await list_active_definitions(entry.runtime_data.database)
assert len(active) == 1
definition_id = active[0].definition.id
await hass.services.call(
DOMAIN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
{"definition_id": definition_id, "is_active": False},
context=ADMIN_CTX,
)
assert await list_active_definitions(entry.runtime_data.database) == []
stored = await QuestDefinitionsDao(entry.runtime_data.database).get(
definition_id
)
assert stored is not None
assert stored.is_active is False
async def test_complete_and_uncomplete_quest_call_business_layer(
hass, make_entry
) -> None:
import datetime
from custom_components.nestquest.children import list_children
from custom_components.nestquest.completion import instance_state
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child.id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = datetime.date.today().isoformat()
end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
records = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child.id, today, end)
assert records
instance_id = records[0].id
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance_id,
"actor": "panel",
"actor_child_id": child.id,
},
)
assert (
await instance_state(entry.runtime_data.database, instance_id)
== "done"
)
await hass.services.call(
DOMAIN,
SERVICE_UNCOMPLETE_QUEST,
{
"instance_id": instance_id,
"actor": "user",
"actor_user_id": ADMIN_ID,
},
context=ADMIN_CTX,
)
assert (
await instance_state(entry.runtime_data.database, instance_id)
== "open"
)
async def test_set_presence_pattern_regenerates_child_instances(
hass, make_entry
) -> None:
import datetime
from custom_components.nestquest.children import list_children
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child.id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = datetime.date.today().isoformat()
end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
dao = QuestInstancesDao(entry.runtime_data.database)
assert await dao.list_by_date_range(child.id, today, end)
await hass.services.call(
DOMAIN,
SERVICE_SET_PRESENCE_PATTERN,
{
"child_id": child.id,
"cycle_length_weeks": 1,
"anchor_date": today,
"pattern": {0: []},
},
context=ADMIN_CTX,
)
assert await dao.list_by_date_range(child.id, today, end) == []
async def test_presence_override_create_and_delete_regenerate(
hass, make_entry
) -> None:
import datetime
from custom_components.nestquest.children import list_children
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.dao_presence import PresenceOverridesDao
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child.id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = datetime.date.today()
end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
dao = QuestInstancesDao(entry.runtime_data.database)
before = await dao.list_by_date_range(
child.id, today.isoformat(), end.isoformat()
)
assert before
await hass.services.call(
DOMAIN,
SERVICE_CREATE_PRESENCE_OVERRIDE,
{
"child_id": child.id,
"start_date": today.isoformat(),
"end_date": end.isoformat(),
"is_present": False,
},
context=ADMIN_CTX,
)
assert (
await dao.list_by_date_range(
child.id, today.isoformat(), end.isoformat()
)
== []
)
override = (
await PresenceOverridesDao(
entry.runtime_data.database
).list_by_child_and_range(
child.id, today.isoformat(), end.isoformat()
)
)[0]
await hass.services.call(
DOMAIN,
SERVICE_DELETE_PRESENCE_OVERRIDE,
{"override_id": override.id},
context=ADMIN_CTX,
)
after = await dao.list_by_date_range(
child.id, today.isoformat(), end.isoformat()
)
assert len(after) == len(before)
async def test_export_history_csv_raises_home_assistant_error(
hass, make_entry
) -> None:
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(HomeAssistantError, match="Feature 13"):
await hass.services.call(
DOMAIN, SERVICE_EXPORT_HISTORY_CSV, context=ADMIN_CTX
)
async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
await hass.services.call(DOMAIN, SERVICE_COMPLETE_QUEST, {})
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{"instance_id": True, "actor": "panel", "actor_child_id": 1},
)
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create"},
)
async def test_admin_only_service_denies_non_admin(hass, make_entry) -> None:
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context={"user_id": "kiosk-panel"},
)
async def test_admin_only_service_denies_missing_user_context(
hass, make_entry
) -> None:
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
)
async def test_admin_only_service_denies_admin_on_empty_allowlist(
hass, make_entry
) -> None:
"""Even the allowlisted id is denied when the allowlist is empty."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
async def test_unauthorized_is_raised_before_handler_runs(
hass, make_entry
) -> None:
from custom_components.nestquest.children import list_children
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context={"user_id": "kiosk-panel"},
)
assert await list_children(entry.runtime_data.database) == []
_MINIMAL_PAYLOADS = {
"uncomplete_quest": {"instance_id": 1, "actor": "user"},
"create_quest_definition": {
"title": "T",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [1],
"windows": ["morning"],
},
"update_quest_definition": {"definition_id": 1},
"set_quest_definition_active": {"definition_id": 1, "is_active": True},
"set_presence_pattern": {
"child_id": 1,
"cycle_length_weeks": 1,
"anchor_date": "2026-09-19",
"pattern": {0: [0]},
},
"create_presence_override": {
"child_id": 1,
"start_date": "2026-09-19",
"end_date": "2026-09-19",
"is_present": False,
},
"delete_presence_override": {"override_id": 1},
"export_history_csv": {},
"manage_child": {"action": "create", "display_name": "Ada"},
"regenerate": {},
}
_ADMIN_ONLY_SERVICES = [
name for name, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
]
@pytest.mark.parametrize("service", _ADMIN_ONLY_SERVICES)
async def test_admin_only_service_fails_closed_without_user_context(
hass, make_entry, service
) -> None:
"""A call with no user_id is denied on every admin-only service."""
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN, service, dict(_MINIMAL_PAYLOADS[service])
)
async def test_complete_quest_proceeds_without_user_context(
hass, make_entry
) -> None:
"""The open service needs no user context: the kiosk panel taps a
child profile and the call goes through with no HA user at all."""
import datetime
from custom_components.nestquest.children import list_children
from custom_components.nestquest.completion import instance_state
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child.id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = datetime.date.today().isoformat()
end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
instance_id = (
await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child.id, today, end)
)[0].id
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
)
assert (
await instance_state(entry.runtime_data.database, instance_id)
== "done"
)
async def test_scheduled_internals_never_call_the_service_layer(
hass, make_entry
) -> None:
"""The daily rollover and startup backfill run the materialization
as direct function calls; they never widen the service gate."""
import custom_components.nestquest as nestquest
assert not hasattr(nestquest, "_run_horizon_materialization_service")
source = Path(nestquest.__file__).read_text(encoding="utf-8")
assert "hass.services.call" not in source, (
"scheduled internals must call the materialization directly, "
"never through hass.services.call"
)
)