Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f49b44b5 | ||
|
|
2a0e568940 | ||
|
|
37d7b7e6c3 | ||
|
|
dd7ea10936 | ||
|
|
4281b9d01c | ||
|
|
a2e9bdffee | ||
|
|
b1c42f10d0 | ||
|
|
2d30f0ce77 | ||
|
|
c4fb2fbac5 | ||
|
|
0820544fce | ||
|
|
318d77f443 | ||
|
|
2ec016ebfe | ||
|
|
c3495bb277 | ||
|
|
775a9906e7 |
@@ -24,11 +24,11 @@ from .const import (
|
|||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
SERVICE_REGENERATE,
|
|
||||||
)
|
)
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
from .materialize import materialize as _materialize_run
|
from .materialize import materialize as _materialize_run
|
||||||
from .migrations import apply_migrations
|
from .migrations import apply_migrations
|
||||||
|
from .services import async_deregister_services, async_register_services
|
||||||
from .store import async_get_db_path
|
from .store import async_get_db_path
|
||||||
from .admin_allowlist import seed_setup_admin
|
from .admin_allowlist import seed_setup_admin
|
||||||
|
|
||||||
@@ -356,21 +356,8 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _register_regenerate_service(hass: HomeAssistant) -> None:
|
def _make_regenerate_handler(hass: HomeAssistant):
|
||||||
"""Register ``nestquest.regenerate`` once at domain scope.
|
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
||||||
|
|
||||||
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:
|
async def _regenerate(_call: Any) -> None:
|
||||||
runtime_data = _find_live_runtime_data(hass)
|
runtime_data = _find_live_runtime_data(hass)
|
||||||
@@ -385,13 +372,29 @@ def _register_regenerate_service(hass: HomeAssistant) -> None:
|
|||||||
hass, runtime_data.database, horizon_days
|
hass, runtime_data.database, horizon_days
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
|
return _regenerate
|
||||||
|
|
||||||
|
|
||||||
def _deregister_regenerate_service(hass: HomeAssistant) -> None:
|
def _register_services(hass: HomeAssistant) -> None:
|
||||||
"""Remove ``nestquest.regenerate`` when the last entry unloads."""
|
"""Register domain-global NestQuest services once.
|
||||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
|
||||||
hass.services.async_remove(DOMAIN, SERVICE_REGENERATE)
|
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)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
@@ -489,7 +492,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await _run_horizon_materialization(
|
await _run_horizon_materialization(
|
||||||
hass, database, _configured_horizon_days(dict(entry.options))
|
hass, database, _configured_horizon_days(dict(entry.options))
|
||||||
)
|
)
|
||||||
_register_regenerate_service(hass)
|
_register_services(hass)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# A failure anywhere after the database is open must not leak the
|
# A failure anywhere after the database is open must not leak the
|
||||||
# listeners already registered against that (now-closed) database:
|
# listeners already registered against that (now-closed) database:
|
||||||
@@ -562,11 +565,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
database = getattr(runtime_data, "database", None)
|
database = getattr(runtime_data, "database", None)
|
||||||
if database is not None:
|
if database is not None:
|
||||||
await database.close()
|
await database.close()
|
||||||
# The ``regenerate`` service is DOMAIN-global: it is torn down only
|
# Domain-global services are torn down only once the FINAL entry
|
||||||
# once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty),
|
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
||||||
# never when a sibling entry is still loaded.
|
# entry is still loaded.
|
||||||
if is_last:
|
if is_last:
|
||||||
_deregister_regenerate_service(hass)
|
_deregister_services(hass)
|
||||||
if getattr(entry, "runtime_data", None) is not None:
|
if getattr(entry, "runtime_data", None) is not None:
|
||||||
entry.runtime_data = None
|
entry.runtime_data = None
|
||||||
if unload_error is not None:
|
if unload_error is not None:
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ Sits between the Feature 09 service gate and the typed
|
|||||||
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
|
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
|
||||||
validation and the UTC occurred-at stamp here, and never talk to the
|
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
|
DAO or SQL directly. This module is the only Feature-08 caller of
|
||||||
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing and
|
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
||||||
un-completing an instance (no-op-if-already-done) arrive in later
|
instance (no-op if already done) is :func:`complete_instance`;
|
||||||
tasks. Instance current state is derived from the latest event via
|
un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
||||||
:func:`instance_state` (``open`` / ``done``); there is no status
|
Instance current state is derived from the latest event via
|
||||||
column.
|
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
|
||||||
|
no status column. ``missed`` is derived for past-due open instances
|
||||||
|
(``due_date`` before HA-local ``today``) and is never stored as an
|
||||||
|
event_type.
|
||||||
|
|
||||||
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
||||||
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
||||||
@@ -25,14 +28,16 @@ is no permission check here (that is Feature 09).
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from .dao_children import _connection_lock
|
from .dao_children import _CONNECTION_LOCKS, _connection_lock
|
||||||
from .dao_instances import (
|
from .dao_instances import (
|
||||||
EVENT_COMPLETED,
|
EVENT_COMPLETED,
|
||||||
EVENT_UNCOMPLETED,
|
EVENT_UNCOMPLETED,
|
||||||
CompletionEventRecord,
|
CompletionEventRecord,
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
|
QuestInstanceRecord,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
)
|
)
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
@@ -62,6 +67,153 @@ 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 _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:
|
def _validate_int_id(value: object, field: str) -> int:
|
||||||
"""Reject non-int ids (bools included) before any lookup."""
|
"""Reject non-int ids (bools included) before any lookup."""
|
||||||
if isinstance(value, bool) or not isinstance(value, int):
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
@@ -172,15 +324,21 @@ async def append_event(
|
|||||||
async def instance_state(
|
async def instance_state(
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
instance_id: int,
|
instance_id: int,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return ``open`` or ``done`` from the instance's latest event.
|
"""Return ``open``, ``done``, or ``missed`` from the latest event.
|
||||||
|
|
||||||
No events, or a latest event of uncompleted, is ``open``. A latest
|
No events, or a latest event of uncompleted, is ``open`` unless
|
||||||
event of completed is ``done``. Unknown ``instance_id`` raises
|
``due_date`` is before ``today`` (HA-local, threaded), in which
|
||||||
ValueError naming the field. Existence and the latest event are
|
case it is ``missed``. A latest event of completed is ``done``
|
||||||
read inside the connection lock so the answer is one snapshot.
|
even when past due. ``missed`` is never stored as an event_type.
|
||||||
|
Unknown ``instance_id`` raises ValueError naming the field.
|
||||||
|
Existence and the latest event are read inside the connection lock
|
||||||
|
so the answer is one snapshot.
|
||||||
"""
|
"""
|
||||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||||
|
today_date = _resolve_today(today)
|
||||||
async with _connection_lock(database):
|
async with _connection_lock(database):
|
||||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||||
if instance is None:
|
if instance is None:
|
||||||
@@ -190,11 +348,148 @@ async def instance_state(
|
|||||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||||
instance_id
|
instance_id
|
||||||
)
|
)
|
||||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
return _derived_state(instance, latest, today_date)
|
||||||
return "open"
|
|
||||||
if latest.event_type == EVENT_COMPLETED:
|
|
||||||
return "done"
|
async def complete_instance(
|
||||||
raise ValueError(
|
database: NestQuestDatabase,
|
||||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
instance_id: int,
|
||||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
*,
|
||||||
|
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
|
||||||
)
|
)
|
||||||
|
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
|
||||||
|
|||||||
@@ -9,9 +9,34 @@ LOGGER = logging.getLogger(LOGGER_NAME)
|
|||||||
|
|
||||||
PLATFORMS: list[str] = []
|
PLATFORMS: list[str] = []
|
||||||
|
|
||||||
#: Service name for the on-demand regeneration service (``nestquest.regenerate``).
|
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||||
|
#: plus the existing Feature 07 regenerate service).
|
||||||
|
SERVICE_COMPLETE_QUEST = "complete_quest"
|
||||||
|
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
|
||||||
|
SERVICE_MANAGE_CHILD = "manage_child"
|
||||||
SERVICE_REGENERATE = "regenerate"
|
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"
|
SQLITE_DB_FILENAME = "nestquest.db"
|
||||||
|
|
||||||
# Configuration keys and default values
|
# Configuration keys and default values
|
||||||
|
|||||||
@@ -270,6 +270,15 @@ class PresenceOverridesDao:
|
|||||||
)
|
)
|
||||||
return [_override_from_row(row) for row in rows]
|
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:
|
async def delete(self, override_id: int) -> bool:
|
||||||
"""Remove one override by id; True if a row was removed."""
|
"""Remove one override by id; True if a row was removed."""
|
||||||
result = await self._database.execute(
|
result = await self._database.execute(
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Explicit admin-only versus open service registry (Feature 09).
|
||||||
|
|
||||||
|
One mapping, one file: every registered ``nestquest`` service name is
|
||||||
|
either ``open`` or ``admin_only``. The permission decorator (later
|
||||||
|
Feature 09 tasks) reads this table; it does not take per-handler
|
||||||
|
policy arguments.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DOMAIN_SERVICES,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
SERVICE_REGENERATE,
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPEN = "open"
|
||||||
|
ADMIN_ONLY = "admin_only"
|
||||||
|
|
||||||
|
SERVICE_POLICY: dict[str, str] = {
|
||||||
|
SERVICE_COMPLETE_QUEST: OPEN,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
|
||||||
|
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
|
||||||
|
SERVICE_REGENERATE: ADMIN_ONLY,
|
||||||
|
}
|
||||||
|
|
||||||
|
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
|
||||||
|
raise RuntimeError(
|
||||||
|
"SERVICE_POLICY must name exactly the registered nestquest services, "
|
||||||
|
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
"""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)
|
||||||
@@ -9,4 +9,318 @@ regenerate:
|
|||||||
horizon, using the same idempotent materialization as the daily rollover
|
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
|
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
|
same rows instead of duplicating them, and completed instances are never
|
||||||
touched.
|
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:
|
||||||
|
|||||||
+31
-6
@@ -177,7 +177,11 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
|
|||||||
_data_entry_flow_mock.FlowResult = dict
|
_data_entry_flow_mock.FlowResult = dict
|
||||||
|
|
||||||
|
|
||||||
class ConfigEntryNotReady(Exception):
|
class HomeAssistantError(Exception):
|
||||||
|
"""Stand-in mirroring homeassistant.exceptions.HomeAssistantError."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigEntryNotReady(HomeAssistantError):
|
||||||
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
|
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
|
||||||
|
|
||||||
HA retries setup when this is raised; the tests assert the corrupt
|
HA retries setup when this is raised; the tests assert the corrupt
|
||||||
@@ -185,7 +189,13 @@ class ConfigEntryNotReady(Exception):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Unauthorized(HomeAssistantError):
|
||||||
|
"""Stand-in mirroring homeassistant.exceptions.Unauthorized."""
|
||||||
|
|
||||||
|
|
||||||
|
_exceptions_mock.HomeAssistantError = HomeAssistantError
|
||||||
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||||
|
_exceptions_mock.Unauthorized = Unauthorized
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -325,7 +335,7 @@ class ServiceRegistry:
|
|||||||
self._services: dict[tuple[str, str], object] = {}
|
self._services: dict[tuple[str, str], object] = {}
|
||||||
|
|
||||||
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
||||||
self._services[(domain, service)] = func
|
self._services[(domain, service)] = (func, schema)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def has_service(self, domain, service):
|
def has_service(self, domain, service):
|
||||||
@@ -335,11 +345,26 @@ class ServiceRegistry:
|
|||||||
self._services.pop((domain, service), None)
|
self._services.pop((domain, service), None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def call(self, domain, service, data=None):
|
async def call(self, domain, service, data=None, context=None):
|
||||||
func = self._services.get((domain, service))
|
registered = self._services.get((domain, service))
|
||||||
if func is None:
|
if registered is None:
|
||||||
raise KeyError(f"service {domain}.{service} not registered")
|
raise KeyError(f"service {domain}.{service} not registered")
|
||||||
call = SimpleNamespace(domain=domain, service=service, data=data or {})
|
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,
|
||||||
|
)
|
||||||
result = func(call)
|
result = func(call)
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
|
|||||||
+1007
-3
File diff suppressed because it is too large
Load Diff
@@ -455,6 +455,20 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
|
|||||||
assert _with_db(tmp_path, "override-list-unknown.db")(_body) == []
|
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:
|
def test_override_delete_round_trip(tmp_path) -> None:
|
||||||
async def _body(database, schedules, overrides, children, child):
|
async def _body(database, schedules, overrides, children, child):
|
||||||
record = await overrides.create(
|
record = await overrides.create(
|
||||||
|
|||||||
+21
-8
@@ -9,6 +9,7 @@ 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 import DOMAIN, async_setup_entry, async_unload_entry
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
@@ -16,6 +17,13 @@ from custom_components.nestquest.const import (
|
|||||||
DOMAIN as DOMAIN_CONST,
|
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):
|
def _wire(entry, registry):
|
||||||
return wire_entry_to_registry(entry, registry)
|
return wire_entry_to_registry(entry, registry)
|
||||||
@@ -546,7 +554,7 @@ async def test_regenerate_service_registered_and_materializes(
|
|||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
entry = _wire(make_entry(), hass.registry)
|
entry = _wire(make_entry(data=_admin_data()), hass.registry)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
@@ -555,7 +563,7 @@ async def test_regenerate_service_registered_and_materializes(
|
|||||||
database = entry.runtime_data.database
|
database = entry.runtime_data.database
|
||||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||||
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
|
||||||
today = _local_today(hass)
|
today = _local_today(hass)
|
||||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
@@ -574,14 +582,15 @@ async def test_regenerate_service_uses_configured_horizon_days(
|
|||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
entry = _wire(
|
entry = _wire(
|
||||||
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
|
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
|
||||||
|
hass.registry,
|
||||||
)
|
)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
|
||||||
database = entry.runtime_data.database
|
database = entry.runtime_data.database
|
||||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||||
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
|
||||||
today = _local_today(hass)
|
today = _local_today(hass)
|
||||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||||
@@ -622,8 +631,12 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
|
|||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
|
entry_a = _wire(
|
||||||
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
|
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
|
||||||
|
)
|
||||||
|
|
||||||
assert await async_setup_entry(hass, entry_a) is True
|
assert await async_setup_entry(hass, entry_a) is True
|
||||||
assert await async_setup_entry(hass, entry_b) is True
|
assert await async_setup_entry(hass, entry_b) is True
|
||||||
@@ -634,7 +647,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
|
|||||||
child_id = await _seed_daily_child_and_definition(
|
child_id = await _seed_daily_child_and_definition(
|
||||||
database_b, _local_today(hass)
|
database_b, _local_today(hass)
|
||||||
)
|
)
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
today = _local_today(hass)
|
today = _local_today(hass)
|
||||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
records = await QuestInstancesDao(database_b).list_by_date_range(
|
records = await QuestInstancesDao(database_b).list_by_date_range(
|
||||||
@@ -647,7 +660,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
|
|||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
# It still works for the remaining entry.
|
# It still works for the remaining entry.
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
|
||||||
# Unloading the final entry removes the domain-global service.
|
# Unloading the final entry removes the domain-global service.
|
||||||
assert await async_unload_entry(hass, entry_b) is True
|
assert await async_unload_entry(hass, entry_b) is True
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""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}
|
||||||
+560
-8
@@ -1,9 +1,33 @@
|
|||||||
"""Tests for the NestQuest services.yaml declaration file."""
|
"""Tests for NestQuest service declarations and domain-global handlers."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
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 = (
|
SERVICES_PATH = (
|
||||||
Path(__file__).parent.parent
|
Path(__file__).parent.parent
|
||||||
/ "custom_components"
|
/ "custom_components"
|
||||||
@@ -12,10 +36,47 @@ 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:
|
def test_services_yaml_exists() -> None:
|
||||||
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
|
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:
|
def test_services_yaml_declares_regenerate() -> None:
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
||||||
@@ -27,15 +88,506 @@ def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
|
|||||||
"""The regenerate service is documented with a clear description and no
|
"""The regenerate service is documented with a clear description and no
|
||||||
required fields (there is no ``fields`` block under it)."""
|
required fields (there is no ``fields`` block under it)."""
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
match = re.search(r"^regenerate:\s*$", text, re.MULTILINE)
|
section = _service_section(text, "regenerate")
|
||||||
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), (
|
assert re.search(r"description:", section), (
|
||||||
"regenerate must carry a description"
|
"regenerate must carry a description"
|
||||||
)
|
)
|
||||||
assert "fields:" not in section, (
|
assert "fields:" not in section, (
|
||||||
"regenerate declares no required fields"
|
"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"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user