Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f49b44b5 | ||
|
|
2a0e568940 | ||
|
|
37d7b7e6c3 | ||
|
|
dd7ea10936 | ||
|
|
4281b9d01c | ||
|
|
a2e9bdffee | ||
|
|
b1c42f10d0 | ||
|
|
2d30f0ce77 | ||
|
|
c4fb2fbac5 | ||
|
|
0820544fce | ||
|
|
318d77f443 | ||
|
|
2ec016ebfe | ||
|
|
c3495bb277 | ||
|
|
775a9906e7 | ||
|
|
293efe9705 | ||
|
|
22797cbc7e | ||
|
|
743703e943 | ||
|
|
6e32761b0c | ||
|
|
3add2d2f48 | ||
|
|
c001aca62d |
@@ -19,15 +19,16 @@ from homeassistant.helpers.event import async_track_time_change
|
|||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
|
CONF_HORIZON_DAYS,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
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
|
||||||
|
|
||||||
@@ -273,10 +274,28 @@ def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
|
|||||||
return int(hour), int(minute)
|
return int(hour), int(minute)
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_horizon_days(options: dict[str, Any]) -> int:
|
||||||
|
"""Return the configured generation horizon, validated, else the default.
|
||||||
|
|
||||||
|
The options flow already validates ``horizon_days`` as an integer >= 1,
|
||||||
|
but a stored ``entry.options`` dict could still carry a partial or
|
||||||
|
hand-edited value. Re-checking here — and falling back to
|
||||||
|
:data:`~.const.DEFAULT_HORIZON_DAYS` on a missing, non-int or sub-1 value
|
||||||
|
— keeps a malformed option from shrinking or exploding the materialized
|
||||||
|
horizon.
|
||||||
|
"""
|
||||||
|
value = options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||||
|
return DEFAULT_HORIZON_DAYS
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
async def _run_horizon_materialization(
|
async def _run_horizon_materialization(
|
||||||
hass: HomeAssistant, database: NestQuestDatabase
|
hass: HomeAssistant,
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
horizon_days: int = DEFAULT_HORIZON_DAYS,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Materialize the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``.
|
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
|
||||||
|
|
||||||
"today" is computed in HA local time (``hass.config.time_zone``) rather
|
"today" is computed in HA local time (``hass.config.time_zone``) rather
|
||||||
than the system clock, so the horizon tracks the household's own day.
|
than the system clock, so the horizon tracks the household's own day.
|
||||||
@@ -285,13 +304,15 @@ async def _run_horizon_materialization(
|
|||||||
|
|
||||||
This is the ONE generation path: the startup backfill, the daily rollover
|
This is the ONE generation path: the startup backfill, the daily rollover
|
||||||
listener and the ``regenerate`` service all funnel through it, so there is
|
listener and the ``regenerate`` service all funnel through it, so there is
|
||||||
no duplicated materialization logic.
|
no duplicated materialization logic. ``horizon_days`` sizes the window
|
||||||
|
and defaults to :data:`~.const.DEFAULT_HORIZON_DAYS`; callers thread the
|
||||||
|
entry's validated option value through.
|
||||||
"""
|
"""
|
||||||
time_zone = ZoneInfo(hass.config.time_zone)
|
time_zone = ZoneInfo(hass.config.time_zone)
|
||||||
today = datetime.datetime.now(time_zone).date()
|
today = datetime.datetime.now(time_zone).date()
|
||||||
start_date = today.isoformat()
|
start_date = today.isoformat()
|
||||||
end_date = (
|
end_date = (
|
||||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
today + datetime.timedelta(days=horizon_days)
|
||||||
).isoformat()
|
).isoformat()
|
||||||
await _materialize_run(database, start_date, end_date, today=today)
|
await _materialize_run(database, start_date, end_date, today=today)
|
||||||
|
|
||||||
@@ -304,67 +325,76 @@ def _register_day_rollover_listener(
|
|||||||
"""Register the daily materialization listener and return its remover.
|
"""Register the daily materialization listener and return its remover.
|
||||||
|
|
||||||
Fires once per day at the configured local ``day_rollover_time`` and
|
Fires once per day at the configured local ``day_rollover_time`` and
|
||||||
materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``
|
materializes the rolling horizon ``[today, today + horizon_days]``
|
||||||
through the shared :func:`_run_horizon_materialization` path.
|
through the shared :func:`_run_horizon_materialization` path, using the
|
||||||
|
entry's configured (validated) ``horizon_days``.
|
||||||
"""
|
"""
|
||||||
hour, minute = _day_rollover_hour_minute(options)
|
hour, minute = _day_rollover_hour_minute(options)
|
||||||
|
horizon_days = _configured_horizon_days(options)
|
||||||
|
|
||||||
async def _run_materialization(_now: datetime.datetime) -> None:
|
async def _run_materialization(_now: datetime.datetime) -> None:
|
||||||
await _run_horizon_materialization(hass, database)
|
await _run_horizon_materialization(hass, database, horizon_days)
|
||||||
|
|
||||||
return async_track_time_change(
|
return async_track_time_change(
|
||||||
hass, _run_materialization, hour=hour, minute=minute
|
hass, _run_materialization, hour=hour, minute=minute
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _find_live_database(hass: HomeAssistant) -> NestQuestDatabase | None:
|
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||||
"""Return a connected database from the active runtime data, or None.
|
"""Return a live runtime record with a connected database, or None.
|
||||||
|
|
||||||
The domain-global services (e.g. ``regenerate``) have no database of
|
The domain-global services (e.g. ``regenerate``) have no database of
|
||||||
their own; they resolve whichever config entry's database is currently
|
their own; they resolve whichever config entry's runtime data is
|
||||||
live at call time, so none of them can hold a stale handle to a closed
|
currently live at call time, so none of them can hold a stale handle
|
||||||
connection after another entry unloads.
|
to a closed connection after another entry unloads. The record also
|
||||||
|
carries that entry's options (e.g. the configured horizon).
|
||||||
"""
|
"""
|
||||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||||
database = getattr(runtime_data, "database", None)
|
database = getattr(runtime_data, "database", None)
|
||||||
if database is not None and database.connected:
|
if database is not None and database.connected:
|
||||||
return database
|
return runtime_data
|
||||||
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:
|
||||||
database = _find_live_database(hass)
|
runtime_data = _find_live_runtime_data(hass)
|
||||||
if database is None:
|
if runtime_data is None:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"NestQuest regenerate requested but no config entry has a "
|
"NestQuest regenerate requested but no config entry has a "
|
||||||
"live database; ignoring"
|
"live database; ignoring"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
await _run_horizon_materialization(hass, database)
|
horizon_days = _configured_horizon_days(runtime_data.options)
|
||||||
|
await _run_horizon_materialization(
|
||||||
|
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:
|
||||||
@@ -456,10 +486,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
)
|
)
|
||||||
# Backfill any days the daily listener missed while the integration
|
# Backfill any days the daily listener missed while the integration
|
||||||
# was off (or freshly installed): run the one idempotent walk over
|
# was off (or freshly installed): run the one idempotent walk over
|
||||||
# [today, today + DEFAULT_HORIZON_DAYS] once at setup. This runs
|
# [today, today + horizon_days] once at setup, using the entry's
|
||||||
# through the SAME path as the daily listener and regenerate service.
|
# configured horizon. This runs through the SAME path as the daily
|
||||||
await _run_horizon_materialization(hass, database)
|
# listener and regenerate service.
|
||||||
_register_regenerate_service(hass)
|
await _run_horizon_materialization(
|
||||||
|
hass, database, _configured_horizon_days(dict(entry.options))
|
||||||
|
)
|
||||||
|
_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:
|
||||||
@@ -532,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:
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
"""Business layer for the completion event log (Feature 08).
|
||||||
|
|
||||||
|
Sits between the Feature 09 service gate and the typed
|
||||||
|
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
|
||||||
|
validation and the UTC occurred-at stamp here, and never talk to the
|
||||||
|
DAO or SQL directly. This module is the only Feature-08 caller of
|
||||||
|
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
||||||
|
instance (no-op if already done) is :func:`complete_instance`;
|
||||||
|
un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
||||||
|
Instance current state is derived from the latest event via
|
||||||
|
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
|
||||||
|
no status column. ``missed`` is derived for past-due open instances
|
||||||
|
(``due_date`` before HA-local ``today``) and is never stored as an
|
||||||
|
event_type.
|
||||||
|
|
||||||
|
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
||||||
|
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
||||||
|
requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'``
|
||||||
|
requires ``actor_child_id`` (the tapped profile) and forbids
|
||||||
|
``actor_user_id``. Inverted shapes raise a field-naming ValueError
|
||||||
|
before any write.
|
||||||
|
|
||||||
|
``occurred_at`` is stamped UTC ISO-8601 at second precision with an
|
||||||
|
explicit ``+00:00`` offset, matching the DAO's one accepted shape so
|
||||||
|
lexicographic range queries stay exact. ``was_on_time`` is required
|
||||||
|
(True/False) for completed events; un-completions may pass None. There
|
||||||
|
is no permission check here (that is Feature 09).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from .dao_children import _CONNECTION_LOCKS, _connection_lock
|
||||||
|
from .dao_instances import (
|
||||||
|
EVENT_COMPLETED,
|
||||||
|
EVENT_UNCOMPLETED,
|
||||||
|
CompletionEventRecord,
|
||||||
|
CompletionEventsDao,
|
||||||
|
QuestInstanceRecord,
|
||||||
|
QuestInstancesDao,
|
||||||
|
)
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
|
||||||
|
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||||
|
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
|
||||||
|
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp() -> str:
|
||||||
|
"""Return the module's strict UTC second-precision stamp."""
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||||
|
_UTC_TIMESTAMP_FORMAT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp_occurred_at(now: datetime.datetime | None) -> str:
|
||||||
|
"""Return a DAO-accepted UTC stamp for ``now``, or the current time."""
|
||||||
|
if now is None:
|
||||||
|
return _now_stamp()
|
||||||
|
if not isinstance(now, datetime.datetime):
|
||||||
|
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||||
|
if now.tzinfo is None:
|
||||||
|
raise ValueError("now must be timezone-aware")
|
||||||
|
return now.astimezone(datetime.timezone.utc).strftime(
|
||||||
|
_UTC_TIMESTAMP_FORMAT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _completion_moment(
|
||||||
|
now: datetime.datetime | None,
|
||||||
|
today: datetime.date | None,
|
||||||
|
) -> tuple[datetime.date, datetime.time | None]:
|
||||||
|
"""Return the HA-local completion date and optional wall-clock time.
|
||||||
|
|
||||||
|
``today`` is the HA-local calendar date when threaded (Feature 07
|
||||||
|
style). ``now`` is a timezone-aware HA-local datetime; its wall
|
||||||
|
clock is used as-is and is never converted to UTC. When ``today``
|
||||||
|
is omitted the date comes from ``now``. When both are omitted the
|
||||||
|
host calendar date is the Feature 07 fallback — never the UTC
|
||||||
|
clock. Time-of-day is None unless ``now`` is supplied; comparing
|
||||||
|
against ``due_time`` requires ``now``.
|
||||||
|
"""
|
||||||
|
if now is not None:
|
||||||
|
if not isinstance(now, datetime.datetime):
|
||||||
|
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||||
|
if now.tzinfo is None:
|
||||||
|
raise ValueError("now must be timezone-aware")
|
||||||
|
if today is not None:
|
||||||
|
completion_date = today
|
||||||
|
elif now is not None:
|
||||||
|
completion_date = now.date()
|
||||||
|
else:
|
||||||
|
completion_date = datetime.date.today()
|
||||||
|
if now is None:
|
||||||
|
return completion_date, None
|
||||||
|
return completion_date, now.time()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||||
|
"""Return the threaded HA-local date, or the host calendar date."""
|
||||||
|
if today is None:
|
||||||
|
return datetime.date.today()
|
||||||
|
return today
|
||||||
|
|
||||||
|
|
||||||
|
def _derived_state(
|
||||||
|
instance: QuestInstanceRecord,
|
||||||
|
latest: CompletionEventRecord | None,
|
||||||
|
today: datetime.date,
|
||||||
|
) -> str:
|
||||||
|
"""Return ``open``, ``done``, or ``missed`` from event plus due date.
|
||||||
|
|
||||||
|
A latest completed event is ``done`` even when past due. No events
|
||||||
|
or a latest uncompleted event is ``open``, or ``missed`` when
|
||||||
|
``due_date`` is before ``today``. ``missed`` is never an event_type.
|
||||||
|
"""
|
||||||
|
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
if due_date < today:
|
||||||
|
return "missed"
|
||||||
|
return "open"
|
||||||
|
if latest.event_type == EVENT_COMPLETED:
|
||||||
|
return "done"
|
||||||
|
raise ValueError(
|
||||||
|
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||||
|
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _was_on_time(
|
||||||
|
instance: QuestInstanceRecord,
|
||||||
|
now: datetime.datetime | None,
|
||||||
|
today: datetime.date | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether the completion moment is on time for ``instance``.
|
||||||
|
|
||||||
|
On ``due_date`` before ``due_time`` is on time; on ``due_date``
|
||||||
|
after ``due_time``, or any later date, is late. With no
|
||||||
|
``due_time``, on time if the completion date equals ``due_date``
|
||||||
|
(or is earlier); late if after ``due_date``. Same-day
|
||||||
|
``due_time`` comparison requires ``now``; UTC time-of-day is never
|
||||||
|
substituted.
|
||||||
|
"""
|
||||||
|
completion_date, completion_time = _completion_moment(now, today)
|
||||||
|
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||||
|
if completion_date < due_date:
|
||||||
|
return True
|
||||||
|
if completion_date > due_date:
|
||||||
|
return False
|
||||||
|
if instance.due_time is None:
|
||||||
|
return True
|
||||||
|
if completion_time is None:
|
||||||
|
raise ValueError("now is required to compare due_time")
|
||||||
|
due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time()
|
||||||
|
return completion_time <= due_time
|
||||||
|
|
||||||
|
|
||||||
|
class _TaskReentrantLock:
|
||||||
|
"""Shared connection lock the owning task may re-enter."""
|
||||||
|
|
||||||
|
def __init__(self, inner: asyncio.Lock) -> None:
|
||||||
|
self._inner = inner
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._depth = 0
|
||||||
|
|
||||||
|
def locked(self) -> bool:
|
||||||
|
return self._inner.locked()
|
||||||
|
|
||||||
|
async def acquire(self) -> bool:
|
||||||
|
task = asyncio.current_task()
|
||||||
|
if self._task is task:
|
||||||
|
self._depth += 1
|
||||||
|
return True
|
||||||
|
await self._inner.acquire()
|
||||||
|
self._task = task
|
||||||
|
self._depth = 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
if self._task is not asyncio.current_task():
|
||||||
|
raise RuntimeError("lock released by a non-owner")
|
||||||
|
self._depth -= 1
|
||||||
|
if self._depth == 0:
|
||||||
|
self._task = None
|
||||||
|
self._inner.release()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _TaskReentrantLock:
|
||||||
|
await self.acquire()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc: object) -> None:
|
||||||
|
self.release()
|
||||||
|
|
||||||
|
|
||||||
|
def _reentrant_connection_lock(database) -> _TaskReentrantLock:
|
||||||
|
"""Return the shared connection lock, made task-reentrant.
|
||||||
|
|
||||||
|
``CompletionEventsDao.append`` also acquires this lock, so
|
||||||
|
check+append in :func:`complete_instance` and
|
||||||
|
:func:`uncomplete_instance` must re-enter on the same task. The
|
||||||
|
wrapper is stored in the shared dict so the DAO sees the same
|
||||||
|
object.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
key = (id(database), id(loop))
|
||||||
|
lock = _CONNECTION_LOCKS.get(key)
|
||||||
|
if isinstance(lock, _TaskReentrantLock):
|
||||||
|
return lock
|
||||||
|
wrapped = _TaskReentrantLock(
|
||||||
|
lock if lock is not None else asyncio.Lock()
|
||||||
|
)
|
||||||
|
_CONNECTION_LOCKS[key] = wrapped
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_int_id(value: object, field: str) -> int:
|
||||||
|
"""Reject non-int ids (bools included) before any lookup."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"{field} must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_event_type(value: object) -> str:
|
||||||
|
"""Return a schema-legal event type, or raise naming the field."""
|
||||||
|
if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||||
|
raise ValueError(
|
||||||
|
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||||
|
f"'{EVENT_UNCOMPLETED}', got {value!r}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_actor(
|
||||||
|
actor_source: object,
|
||||||
|
actor_user_id: object,
|
||||||
|
actor_child_id: object,
|
||||||
|
) -> tuple[str, str | None, int | None]:
|
||||||
|
"""Return a schema-legal actor triple, or raise naming the field.
|
||||||
|
|
||||||
|
``'user'`` requires a non-empty ``actor_user_id`` string and forbids
|
||||||
|
``actor_child_id``. ``'panel'`` requires an integer
|
||||||
|
``actor_child_id`` and forbids ``actor_user_id``. Any other
|
||||||
|
``actor_source`` (including ``'service'``) is rejected.
|
||||||
|
"""
|
||||||
|
if actor_source not in ("user", "panel"):
|
||||||
|
raise ValueError(
|
||||||
|
f"actor_source must be 'user' or 'panel', got {actor_source!r}"
|
||||||
|
)
|
||||||
|
if actor_source == "user":
|
||||||
|
if not isinstance(actor_user_id, str) or not actor_user_id.strip():
|
||||||
|
raise ValueError("actor_source 'user' requires actor_user_id")
|
||||||
|
if actor_child_id is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"actor_source 'user' must not carry actor_child_id"
|
||||||
|
)
|
||||||
|
return "user", actor_user_id.strip(), None
|
||||||
|
if actor_user_id is not None:
|
||||||
|
raise ValueError("actor_source 'panel' must not carry actor_user_id")
|
||||||
|
if actor_child_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"actor_source 'panel' requires actor_child_id"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"panel",
|
||||||
|
None,
|
||||||
|
_validate_int_id(actor_child_id, "actor_child_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_was_on_time(
|
||||||
|
event_type: str, was_on_time: object
|
||||||
|
) -> bool | None:
|
||||||
|
"""Return a real bool or None; completed events require True/False."""
|
||||||
|
if was_on_time is not None and type(was_on_time) is not bool:
|
||||||
|
raise ValueError(
|
||||||
|
f"was_on_time must be True, False or None, got {was_on_time!r}"
|
||||||
|
)
|
||||||
|
if event_type == EVENT_COMPLETED and was_on_time is None:
|
||||||
|
raise ValueError(
|
||||||
|
"a completed event requires was_on_time (True/False)"
|
||||||
|
)
|
||||||
|
return was_on_time
|
||||||
|
|
||||||
|
|
||||||
|
async def append_event(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
instance_id: int,
|
||||||
|
child_id: int,
|
||||||
|
event_type: str,
|
||||||
|
*,
|
||||||
|
actor_source: str,
|
||||||
|
actor_user_id: str | None = None,
|
||||||
|
actor_child_id: int | None = None,
|
||||||
|
was_on_time: bool | None = None,
|
||||||
|
now: datetime.datetime | None = None,
|
||||||
|
) -> CompletionEventRecord:
|
||||||
|
"""Append one completion or un-completion event and return the row.
|
||||||
|
|
||||||
|
Validates actor shapes and the completed-event on-time requirement
|
||||||
|
before calling :meth:`CompletionEventsDao.append`. ``occurred_at``
|
||||||
|
is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock.
|
||||||
|
"""
|
||||||
|
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||||
|
child_id = _validate_int_id(child_id, "child_id")
|
||||||
|
event_type = _validate_event_type(event_type)
|
||||||
|
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||||
|
actor_source, actor_user_id, actor_child_id
|
||||||
|
)
|
||||||
|
was_on_time = _validate_was_on_time(event_type, was_on_time)
|
||||||
|
occurred_at = _stamp_occurred_at(now)
|
||||||
|
return await CompletionEventsDao(database).append(
|
||||||
|
instance_id,
|
||||||
|
child_id,
|
||||||
|
event_type,
|
||||||
|
actor_source,
|
||||||
|
occurred_at,
|
||||||
|
was_on_time,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
actor_child_id=actor_child_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def instance_state(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
instance_id: int,
|
||||||
|
*,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return ``open``, ``done``, or ``missed`` from the latest event.
|
||||||
|
|
||||||
|
No events, or a latest event of uncompleted, is ``open`` unless
|
||||||
|
``due_date`` is before ``today`` (HA-local, threaded), in which
|
||||||
|
case it is ``missed``. A latest event of completed is ``done``
|
||||||
|
even when past due. ``missed`` is never stored as an event_type.
|
||||||
|
Unknown ``instance_id`` raises ValueError naming the field.
|
||||||
|
Existence and the latest event are read inside the connection lock
|
||||||
|
so the answer is one snapshot.
|
||||||
|
"""
|
||||||
|
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||||
|
today_date = _resolve_today(today)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"instance_id: quest instance {instance_id} does not exist"
|
||||||
|
)
|
||||||
|
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||||
|
instance_id
|
||||||
|
)
|
||||||
|
return _derived_state(instance, latest, today_date)
|
||||||
|
|
||||||
|
|
||||||
|
async def complete_instance(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
instance_id: int,
|
||||||
|
*,
|
||||||
|
actor_source: str,
|
||||||
|
actor_user_id: str | None = None,
|
||||||
|
actor_child_id: int | None = None,
|
||||||
|
now: datetime.datetime | None = None,
|
||||||
|
today: datetime.date | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Append a completed event unless the instance is already done.
|
||||||
|
|
||||||
|
Returns the derived state ``done``. An already-done instance is a
|
||||||
|
no-op: nothing is appended. Unknown ``instance_id`` raises
|
||||||
|
ValueError naming the field. Existence, latest-event, and the
|
||||||
|
append run under one connection lock so a concurrent complete
|
||||||
|
cannot double-append.
|
||||||
|
|
||||||
|
``was_on_time`` is computed from the HA-local completion moment
|
||||||
|
(threaded ``now`` / ``today``) against the instance ``due_date``
|
||||||
|
and optional ``due_time``. ``now`` is the timezone-aware HA-local
|
||||||
|
datetime; its wall clock is never converted to UTC. Same-day
|
||||||
|
``due_time`` comparison requires ``now``.
|
||||||
|
"""
|
||||||
|
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||||
|
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||||
|
actor_source, actor_user_id, actor_child_id
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|||||||
@@ -344,14 +344,20 @@ class QuestInstancesDao:
|
|||||||
- the (definition, child) assignment link still exists (a removed
|
- the (definition, child) assignment link still exists (a removed
|
||||||
assignment is skipped);
|
assignment is skipped);
|
||||||
- the window is still declared on the definition (a removed window
|
- the window is still declared on the definition (a removed window
|
||||||
is skipped).
|
is skipped);
|
||||||
|
- the window's live ``due_time`` still EQUALS the caller-supplied
|
||||||
|
``due_time`` (NULL-safe: NULL == NULL counts as equal; a window
|
||||||
|
with no due time matches a snapshot with no due time). A
|
||||||
|
window-time edit landed after the input snapshot makes the
|
||||||
|
caller's ``due_time`` stale, and writing it would overwrite the
|
||||||
|
newer due_time a concurrent regeneration already upserted — so
|
||||||
|
the tuple is skipped instead of written.
|
||||||
|
|
||||||
The live checks above ONLY decide whether the tuple is still
|
The due_time check closes the one check-then-insert gap this
|
||||||
valid; they never read the window's ``due_time``. The instance is
|
method previously left open: it re-validated every OTHER
|
||||||
written with the caller-supplied ``due_time`` — the value snapshotted
|
precondition but then wrote the snapshot's ``due_time``
|
||||||
from the coherent input read — so a window-time edit landing
|
unconditionally, so a stale materialization could clobber a
|
||||||
mid-walk cannot leak a newer time into a batch that was already
|
newer, concurrently-regenerated due_time.
|
||||||
snapshotted against older rule/presence/assignment decisions.
|
|
||||||
|
|
||||||
Returns the stored instance, or ``None`` when a precondition
|
Returns the stored instance, or ``None`` when a precondition
|
||||||
failed — the tuple no longer materializable, which the walk SKIPS
|
failed — the tuple no longer materializable, which the walk SKIPS
|
||||||
@@ -384,8 +390,9 @@ class QuestInstancesDao:
|
|||||||
"JOIN quest_definition_windows w "
|
"JOIN quest_definition_windows w "
|
||||||
"ON w.definition_id = d.id "
|
"ON w.definition_id = d.id "
|
||||||
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
|
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
|
||||||
"AND d.is_active = 1 AND c.is_active = 1 LIMIT 1",
|
"AND d.is_active = 1 AND c.is_active = 1 "
|
||||||
(definition_id, child_id, window),
|
"AND w.due_time IS ? LIMIT 1",
|
||||||
|
(definition_id, child_id, window, due_time),
|
||||||
)
|
)
|
||||||
if still_valid is None:
|
if still_valid is None:
|
||||||
# A config change invalidated the tuple since the
|
# A config change invalidated the tuple since the
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
|
|||||||
covers a single child's presence change (Feature 09). Both delete the
|
covers a single child's presence change (Feature 09). Both delete the
|
||||||
affected open instances at or after today — never a completed instance,
|
affected open instances at or after today — never a completed instance,
|
||||||
never the past — and re-run the walk over the rolling horizon
|
never the past — and re-run the walk over the rolling horizon
|
||||||
``[today, today + DEFAULT_HORIZON_DAYS]``.
|
``[today, today + horizon_days]`` (``horizon_days`` defaults to
|
||||||
|
``DEFAULT_HORIZON_DAYS``).
|
||||||
|
|
||||||
HOOK (Feature 09 presence services): the presence business layer that
|
HOOK (Feature 09 presence services): the presence business layer that
|
||||||
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
|
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
|
||||||
@@ -83,6 +84,31 @@ def _now_stamp() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_horizon_days(horizon_days: int | None) -> int:
|
||||||
|
"""Validate a regeneration horizon and return the resolved day count.
|
||||||
|
|
||||||
|
``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool
|
||||||
|
(SQLite would happily bind ``True``/``False`` onto an int), a non-int,
|
||||||
|
or a value below 1 raises ValueError naming the field — the validation
|
||||||
|
runs BEFORE any future-instance delete so a malformed horizon can never
|
||||||
|
delete rows it then fails to rebuild. A negative horizon would
|
||||||
|
otherwise delete first and only then be rejected by
|
||||||
|
:func:`materialize`'s inverted-range check; ``0``/``False`` would
|
||||||
|
silently shrink the window to a single day.
|
||||||
|
"""
|
||||||
|
if horizon_days is None:
|
||||||
|
return DEFAULT_HORIZON_DAYS
|
||||||
|
if (
|
||||||
|
isinstance(horizon_days, bool)
|
||||||
|
or not isinstance(horizon_days, int)
|
||||||
|
or horizon_days < 1
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"horizon_days must be a positive integer, got {horizon_days!r}"
|
||||||
|
)
|
||||||
|
return horizon_days
|
||||||
|
|
||||||
|
|
||||||
def _decode_rule(snapshot) -> ScheduleRule:
|
def _decode_rule(snapshot) -> ScheduleRule:
|
||||||
"""Decode a snapshot's rule record back into a ScheduleRule.
|
"""Decode a snapshot's rule record back into a ScheduleRule.
|
||||||
|
|
||||||
@@ -232,6 +258,7 @@ async def regenerate_for_definition(
|
|||||||
definition_id: int,
|
definition_id: int,
|
||||||
*,
|
*,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Regenerate a definition's future instances after a config change.
|
"""Regenerate a definition's future instances after a config change.
|
||||||
|
|
||||||
@@ -241,7 +268,7 @@ async def regenerate_for_definition(
|
|||||||
completed instance, never the past) via
|
completed instance, never the past) via
|
||||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
|
||||||
then the materialization walk re-runs over the rolling horizon
|
then the materialization walk re-runs over the rolling horizon
|
||||||
``[today, today + DEFAULT_HORIZON_DAYS]`` so the (changed) rule,
|
``[today, today + horizon_days]`` so the (changed) rule,
|
||||||
assignee set and windows re-materialize against today's state.
|
assignee set and windows re-materialize against today's state.
|
||||||
|
|
||||||
"today" is computed the same way the walk computes it —
|
"today" is computed the same way the walk computes it —
|
||||||
@@ -251,14 +278,18 @@ async def regenerate_for_definition(
|
|||||||
range. ``today`` optionally pins a caller-resolved HA-local date
|
range. ``today`` optionally pins a caller-resolved HA-local date
|
||||||
(threaded into both the delete cutoff and the re-materialization)
|
(threaded into both the delete cutoff and the re-materialization)
|
||||||
so a household time zone behind the host around midnight stays
|
so a household time zone behind the host around midnight stays
|
||||||
consistent. Returns the number of instances the re-materialization
|
consistent. ``horizon_days`` sizes the re-materialization window;
|
||||||
|
when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`.
|
||||||
|
A bool, non-int, or sub-1 value raises ValueError BEFORE any delete.
|
||||||
|
Returns the number of instances the re-materialization
|
||||||
upserted across the whole snapshot (idempotent — other active
|
upserted across the whole snapshot (idempotent — other active
|
||||||
definitions' tuples refresh in place, never duplicate).
|
definitions' tuples refresh in place, never duplicate).
|
||||||
"""
|
"""
|
||||||
|
horizon = _resolve_horizon_days(horizon_days)
|
||||||
today_date = _resolve_today(today)
|
today_date = _resolve_today(today)
|
||||||
start_date = today_date.isoformat()
|
start_date = today_date.isoformat()
|
||||||
end_date = (
|
end_date = (
|
||||||
today_date + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
today_date + datetime.timedelta(days=horizon)
|
||||||
).isoformat()
|
).isoformat()
|
||||||
|
|
||||||
instances = QuestInstancesDao(database)
|
instances = QuestInstancesDao(database)
|
||||||
@@ -286,6 +317,7 @@ async def regenerate_for_child(
|
|||||||
child_id: int,
|
child_id: int,
|
||||||
*,
|
*,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Regenerate a child's future instances after a presence change.
|
"""Regenerate a child's future instances after a presence change.
|
||||||
|
|
||||||
@@ -299,7 +331,7 @@ async def regenerate_for_child(
|
|||||||
(never a completed instance, never the past) via
|
(never a completed instance, never the past) via
|
||||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
||||||
then the materialization walk re-runs over the rolling horizon
|
then the materialization walk re-runs over the rolling horizon
|
||||||
``[today, today + DEFAULT_HORIZON_DAYS]`` scoped to that child only,
|
``[today, today + horizon_days]`` scoped to that child only,
|
||||||
so the child's now-absent/present dates re-materialize against
|
so the child's now-absent/present dates re-materialize against
|
||||||
today's presence WITHOUT touching any other child's rows.
|
today's presence WITHOUT touching any other child's rows.
|
||||||
|
|
||||||
@@ -315,13 +347,17 @@ async def regenerate_for_child(
|
|||||||
execution-day anchor even when the call is queued across midnight.
|
execution-day anchor even when the call is queued across midnight.
|
||||||
``today`` optionally pins a caller-resolved HA-local date, threaded
|
``today`` optionally pins a caller-resolved HA-local date, threaded
|
||||||
into the delete cutoff and the re-materialization for the same
|
into the delete cutoff and the re-materialization for the same
|
||||||
host-vs-HA time-zone consistency. Returns the number of instances
|
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
|
||||||
|
re-materialization window; when omitted it falls back to
|
||||||
|
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
|
||||||
|
raises ValueError BEFORE any delete. Returns the number of instances
|
||||||
the re-materialization upserted for the child across the whole
|
the re-materialization upserted for the child across the whole
|
||||||
snapshot.
|
snapshot.
|
||||||
"""
|
"""
|
||||||
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||||
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||||
|
|
||||||
|
horizon = _resolve_horizon_days(horizon_days)
|
||||||
today_date = _resolve_today(today)
|
today_date = _resolve_today(today)
|
||||||
|
|
||||||
instances = QuestInstancesDao(database)
|
instances = QuestInstancesDao(database)
|
||||||
@@ -335,7 +371,7 @@ async def regenerate_for_child(
|
|||||||
child_id, today_date.isoformat(), today=today
|
child_id, today_date.isoformat(), today=today
|
||||||
)
|
)
|
||||||
start = datetime.date.fromisoformat(effective_cutoff)
|
start = datetime.date.fromisoformat(effective_cutoff)
|
||||||
end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
|
||||||
return await materialize(
|
return await materialize(
|
||||||
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -272,6 +272,7 @@ async def edit_quest_definition(
|
|||||||
rule: ScheduleRule | object = _UNSET,
|
rule: ScheduleRule | object = _UNSET,
|
||||||
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
) -> CreatedQuestDefinition:
|
) -> CreatedQuestDefinition:
|
||||||
"""Edit a quest definition's metadata, rule and windows atomically.
|
"""Edit a quest definition's metadata, rule and windows atomically.
|
||||||
|
|
||||||
@@ -299,7 +300,10 @@ async def edit_quest_definition(
|
|||||||
into :func:`~.materialize.regenerate_for_definition`); it is a plain
|
into :func:`~.materialize.regenerate_for_definition`); it is a plain
|
||||||
``datetime.date`` with no HA import here — the Feature 09 service
|
``datetime.date`` with no HA import here — the Feature 09 service
|
||||||
supplies the hass-derived value. When omitted, regeneration falls
|
supplies the hass-derived value. When omitted, regeneration falls
|
||||||
back to the host clock, same as before.
|
back to the host clock, same as before. ``horizon_days`` sizes the
|
||||||
|
regeneration's re-materialization window the same way (threaded into
|
||||||
|
:func:`~.materialize.regenerate_for_definition`); it defaults to
|
||||||
|
:data:`~.const.DEFAULT_HORIZON_DAYS` when omitted.
|
||||||
"""
|
"""
|
||||||
_validate_definition_id(definition_id)
|
_validate_definition_id(definition_id)
|
||||||
|
|
||||||
@@ -350,7 +354,9 @@ async def edit_quest_definition(
|
|||||||
schedule_rule_storage_from_record(snapshot.rule)
|
schedule_rule_storage_from_record(snapshot.rule)
|
||||||
)
|
)
|
||||||
|
|
||||||
await regenerate_for_definition(database, definition_id, today=today)
|
await regenerate_for_definition(
|
||||||
|
database, definition_id, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
|
|
||||||
return CreatedQuestDefinition(
|
return CreatedQuestDefinition(
|
||||||
definition=snapshot.definition,
|
definition=snapshot.definition,
|
||||||
@@ -366,6 +372,7 @@ async def assign_child(
|
|||||||
child_id: int,
|
child_id: int,
|
||||||
*,
|
*,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
) -> list[ChildRecord]:
|
) -> list[ChildRecord]:
|
||||||
"""Assign ``child_id`` to the definition (idempotent).
|
"""Assign ``child_id`` to the definition (idempotent).
|
||||||
|
|
||||||
@@ -382,6 +389,8 @@ async def assign_child(
|
|||||||
|
|
||||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||||
into the assignment's regeneration (see :func:`edit_quest_definition`).
|
into the assignment's regeneration (see :func:`edit_quest_definition`).
|
||||||
|
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||||
|
window the same way.
|
||||||
"""
|
"""
|
||||||
_validate_definition_id(definition_id)
|
_validate_definition_id(definition_id)
|
||||||
_validate_child_id(child_id)
|
_validate_child_id(child_id)
|
||||||
@@ -397,7 +406,9 @@ async def assign_child(
|
|||||||
if message.startswith("quest definition "):
|
if message.startswith("quest definition "):
|
||||||
raise ValueError(f"definition_id: {message}") from error
|
raise ValueError(f"definition_id: {message}") from error
|
||||||
raise
|
raise
|
||||||
await regenerate_for_definition(database, definition_id, today=today)
|
await regenerate_for_definition(
|
||||||
|
database, definition_id, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
return assignees
|
return assignees
|
||||||
|
|
||||||
|
|
||||||
@@ -407,6 +418,7 @@ async def unassign_child(
|
|||||||
child_id: int,
|
child_id: int,
|
||||||
*,
|
*,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Unassign ``child_id`` from the definition; True when removed.
|
"""Unassign ``child_id`` from the definition; True when removed.
|
||||||
|
|
||||||
@@ -419,6 +431,8 @@ async def unassign_child(
|
|||||||
|
|
||||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||||
into the unassignment's regeneration (see :func:`edit_quest_definition`).
|
into the unassignment's regeneration (see :func:`edit_quest_definition`).
|
||||||
|
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||||
|
window the same way.
|
||||||
"""
|
"""
|
||||||
_validate_definition_id(definition_id)
|
_validate_definition_id(definition_id)
|
||||||
_validate_child_id(child_id)
|
_validate_child_id(child_id)
|
||||||
@@ -429,7 +443,9 @@ async def unassign_child(
|
|||||||
f"definition_id: quest definition {definition_id} does not exist"
|
f"definition_id: quest definition {definition_id} does not exist"
|
||||||
)
|
)
|
||||||
removed = await dao.remove_assignee(definition_id, child_id)
|
removed = await dao.remove_assignee(definition_id, child_id)
|
||||||
await regenerate_for_definition(database, definition_id, today=today)
|
await regenerate_for_definition(
|
||||||
|
database, definition_id, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
@@ -439,6 +455,7 @@ async def set_quest_definition_active(
|
|||||||
is_active: bool,
|
is_active: bool,
|
||||||
*,
|
*,
|
||||||
today: datetime.date | None = None,
|
today: datetime.date | None = None,
|
||||||
|
horizon_days: int | None = None,
|
||||||
) -> CreatedQuestDefinition:
|
) -> CreatedQuestDefinition:
|
||||||
"""Deactivate or reactivate a definition; returns the updated view.
|
"""Deactivate or reactivate a definition; returns the updated view.
|
||||||
|
|
||||||
@@ -464,7 +481,8 @@ async def set_quest_definition_active(
|
|||||||
|
|
||||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||||
into the activation change's regeneration (see
|
into the activation change's regeneration (see
|
||||||
:func:`edit_quest_definition`).
|
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
|
||||||
|
regeneration's re-materialization window the same way.
|
||||||
"""
|
"""
|
||||||
_validate_definition_id(definition_id)
|
_validate_definition_id(definition_id)
|
||||||
if not isinstance(is_active, bool):
|
if not isinstance(is_active, bool):
|
||||||
@@ -488,7 +506,9 @@ async def set_quest_definition_active(
|
|||||||
rule = schedule_rule_from_storage(
|
rule = schedule_rule_from_storage(
|
||||||
schedule_rule_storage_from_record(rule_record)
|
schedule_rule_storage_from_record(rule_record)
|
||||||
)
|
)
|
||||||
await regenerate_for_definition(database, definition_id, today=today)
|
await regenerate_for_definition(
|
||||||
|
database, definition_id, today=today, horizon_days=horizon_days
|
||||||
|
)
|
||||||
return CreatedQuestDefinition(
|
return CreatedQuestDefinition(
|
||||||
definition=updated,
|
definition=updated,
|
||||||
rule=rule,
|
rule=rule,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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(
|
||||||
|
|||||||
+144
-6
@@ -9,12 +9,21 @@ 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,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
SERVICE_REGENERATE,
|
SERVICE_REGENERATE,
|
||||||
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)
|
||||||
@@ -351,6 +360,39 @@ async def test_day_rollover_listener_materializes_ha_local_horizon(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_day_rollover_uses_configured_horizon_days(hass, make_entry) -> None:
|
||||||
|
"""The daily run materializes only the configured horizon window."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
|
entry = _wire(make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
|
||||||
|
# A definition created AFTER setup has no instances yet (create does not
|
||||||
|
# materialize); firing the daily listener must generate only the horizon.
|
||||||
|
child_id = await _seed_daily_child_and_definition(
|
||||||
|
database, _local_today(hass)
|
||||||
|
)
|
||||||
|
await hass.time_change.fire()
|
||||||
|
|
||||||
|
today = _local_today(hass)
|
||||||
|
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||||
|
records = await QuestInstancesDao(database).list_by_date_range(
|
||||||
|
child_id, today.isoformat(), end
|
||||||
|
)
|
||||||
|
assert len(records) == 6
|
||||||
|
|
||||||
|
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||||
|
beyond_end = (
|
||||||
|
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
assert await QuestInstancesDao(database).list_by_date_range(
|
||||||
|
child_id, beyond, beyond_end
|
||||||
|
) == []
|
||||||
|
|
||||||
|
|
||||||
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
|
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
|
||||||
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
|
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
|
||||||
|
|
||||||
@@ -447,6 +489,63 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
|
|||||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None:
|
||||||
|
"""Setup backfills only the configured (non-default) horizon window."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
from custom_components.nestquest.migrations import apply_migrations
|
||||||
|
from custom_components.nestquest.store import async_get_db_path
|
||||||
|
|
||||||
|
db_path = await async_get_db_path(hass)
|
||||||
|
pre = NestQuestDatabase(hass)
|
||||||
|
await pre.open(db_path)
|
||||||
|
await apply_migrations(pre)
|
||||||
|
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||||
|
await pre.close()
|
||||||
|
|
||||||
|
entry = _wire(
|
||||||
|
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
|
||||||
|
)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
|
||||||
|
today = _local_today(hass)
|
||||||
|
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||||
|
records = await QuestInstancesDao(
|
||||||
|
entry.runtime_data.database
|
||||||
|
).list_by_date_range(child_id, today.isoformat(), end)
|
||||||
|
assert len(records) == 6
|
||||||
|
|
||||||
|
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||||
|
beyond_end = (
|
||||||
|
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
assert await QuestInstancesDao(
|
||||||
|
entry.runtime_data.database
|
||||||
|
).list_by_date_range(child_id, beyond, beyond_end) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured_horizon_days_validates_and_falls_back() -> None:
|
||||||
|
"""The configured horizon is validated; a malformed value uses the default."""
|
||||||
|
import custom_components.nestquest as nestquest
|
||||||
|
|
||||||
|
assert nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 5}) == 5
|
||||||
|
assert nestquest._configured_horizon_days({}) == DEFAULT_HORIZON_DAYS
|
||||||
|
assert (
|
||||||
|
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: True})
|
||||||
|
== DEFAULT_HORIZON_DAYS
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: "5"})
|
||||||
|
== DEFAULT_HORIZON_DAYS
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 0})
|
||||||
|
== DEFAULT_HORIZON_DAYS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_regenerate_service_registered_and_materializes(
|
async def test_regenerate_service_registered_and_materializes(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -455,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)
|
||||||
|
|
||||||
@@ -464,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()
|
||||||
@@ -474,6 +573,41 @@ async def test_regenerate_service_registered_and_materializes(
|
|||||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_regenerate_service_uses_configured_horizon_days(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""``nestquest.regenerate`` regenerates only the configured horizon."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
|
entry = _wire(
|
||||||
|
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
|
||||||
|
hass.registry,
|
||||||
|
)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||||
|
|
||||||
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
|
||||||
|
today = _local_today(hass)
|
||||||
|
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||||
|
records = await QuestInstancesDao(database).list_by_date_range(
|
||||||
|
child_id, today.isoformat(), end
|
||||||
|
)
|
||||||
|
assert len(records) == 6
|
||||||
|
|
||||||
|
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||||
|
beyond_end = (
|
||||||
|
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
assert await QuestInstancesDao(database).list_by_date_range(
|
||||||
|
child_id, beyond, beyond_end
|
||||||
|
) == []
|
||||||
|
|
||||||
|
|
||||||
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
|
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
|
||||||
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
|
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
|
||||||
entry = _wire(make_entry(), hass.registry)
|
entry = _wire(make_entry(), hass.registry)
|
||||||
@@ -497,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
|
||||||
@@ -509,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(
|
||||||
@@ -522,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
|
||||||
|
|||||||
+203
-7
@@ -7,6 +7,7 @@ import datetime
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.dao_instances import (
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
@@ -544,7 +545,7 @@ def test_materialize_uses_single_presence_snapshot_mid_walk(tmp_path) -> None:
|
|||||||
_with_db(tmp_path, "materialize-presence-snapshot.db")(_body)
|
_with_db(tmp_path, "materialize-presence-snapshot.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
def test_materialize_stores_snapshot_due_time_not_live(tmp_path) -> None:
|
def test_materialize_skips_stale_due_time_mid_walk(tmp_path) -> None:
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
children = ChildrenDao(database)
|
children = ChildrenDao(database)
|
||||||
child = await children.create("Ada", NOW)
|
child = await children.create("Ada", NOW)
|
||||||
@@ -565,21 +566,64 @@ def test_materialize_stores_snapshot_due_time_not_live(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# The window's due_time is edited to 10:15 between the input
|
# The window's due_time is edited to 10:15 between the input
|
||||||
# snapshot and the insert. The walk must store the SNAPSHOT's
|
# snapshot and the insert. The walk's snapshotted 09:00 no
|
||||||
# 09:00, not the live 10:15 — the batch is governed entirely by
|
# longer matches the live 10:15, so the tuple is SKIPPED rather
|
||||||
# the coherent snapshot it read.
|
# than writing a due_time that has already been superseded.
|
||||||
count = await _materialize_with_change(
|
count = await _materialize_with_change(
|
||||||
database, start_iso, end_iso, _edit_due_time
|
database, start_iso, end_iso, _edit_due_time
|
||||||
)
|
)
|
||||||
assert count == 3
|
assert count == 0
|
||||||
|
assert await QuestInstancesDao(database).list_by_date_range(
|
||||||
|
child.id, start_iso, end_iso
|
||||||
|
) == []
|
||||||
|
return count
|
||||||
|
|
||||||
|
_with_db(tmp_path, "materialize-stale-due-time.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_does_not_overwrite_regenerated_due_time(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
child = await children.create("Ada", NOW)
|
||||||
|
|
||||||
|
today = datetime.date.today()
|
||||||
|
start_iso = today.isoformat()
|
||||||
|
end_iso = (today + datetime.timedelta(days=2)).isoformat()
|
||||||
|
|
||||||
|
created = await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Daily chore",
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
|
||||||
|
[child.id],
|
||||||
|
[("morning", "09:00")],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _edit_and_regenerate():
|
||||||
|
# A window-time edit + regeneration land after the stale walk
|
||||||
|
# read its input snapshot: the window moves to 10:15 and the
|
||||||
|
# regeneration upserts the newer due_time across the horizon.
|
||||||
|
await QuestDefinitionsDao(database).upsert_window(
|
||||||
|
created.definition.id, "morning", due_time="10:15"
|
||||||
|
)
|
||||||
|
await regenerate_for_definition(database, created.definition.id)
|
||||||
|
|
||||||
|
# The stale walk (snapshot due_time 09:00) then resumes. It must
|
||||||
|
# SKIP these tuples — its snapshotted 09:00 no longer matches the
|
||||||
|
# live 10:15 — so the regeneration's newer due_time survives and
|
||||||
|
# is NOT overwritten by the stale value.
|
||||||
|
count = await _materialize_with_change(
|
||||||
|
database, start_iso, end_iso, _edit_and_regenerate
|
||||||
|
)
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
records = await QuestInstancesDao(database).list_by_date_range(
|
records = await QuestInstancesDao(database).list_by_date_range(
|
||||||
child.id, start_iso, end_iso
|
child.id, start_iso, end_iso
|
||||||
)
|
)
|
||||||
assert len(records) == 3
|
assert len(records) == 3
|
||||||
assert {r.due_time for r in records} == {"09:00"}
|
assert {r.due_time for r in records} == {"10:15"}
|
||||||
return records
|
return records
|
||||||
|
|
||||||
_with_db(tmp_path, "materialize-snapshot-due-time.db")(_body)
|
_with_db(tmp_path, "materialize-regenerated-due-time.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
def test_materialize_honours_overrides(tmp_path) -> None:
|
def test_materialize_honours_overrides(tmp_path) -> None:
|
||||||
@@ -1113,6 +1157,158 @@ def test_regenerate_for_child_is_child_scoped(tmp_path) -> None:
|
|||||||
_with_db(tmp_path, "regenerate-child-scoped.db")(_body)
|
_with_db(tmp_path, "regenerate-child-scoped.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_for_definition_respects_horizon_days(tmp_path) -> None:
|
||||||
|
"""A non-default horizon_days sizes the definition's rebuild window."""
|
||||||
|
async def _body(database):
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
child = await children.create("Ada", NOW)
|
||||||
|
|
||||||
|
today = datetime.date.today()
|
||||||
|
start_iso = today.isoformat()
|
||||||
|
|
||||||
|
created = await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Daily chore",
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
|
||||||
|
[child.id],
|
||||||
|
[("morning", "09:00")],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ``create`` does not materialize; regeneration over a 5-day horizon
|
||||||
|
# must build exactly today..today+5 (six daily rows).
|
||||||
|
await regenerate_for_definition(
|
||||||
|
database, created.definition.id, horizon_days=5
|
||||||
|
)
|
||||||
|
|
||||||
|
dao = QuestInstancesDao(database)
|
||||||
|
horizon_end = today + datetime.timedelta(days=5)
|
||||||
|
records = await dao.list_by_date_range(
|
||||||
|
child.id, start_iso, horizon_end.isoformat()
|
||||||
|
)
|
||||||
|
assert len(records) == 6
|
||||||
|
|
||||||
|
# The window is BOUNDED: nothing must exist at day 6 or later (a
|
||||||
|
# hard-coded 14-day default would still emit rows there).
|
||||||
|
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||||
|
beyond_end = (
|
||||||
|
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
assert await dao.list_by_date_range(child.id, beyond, beyond_end) == []
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "regenerate-def-horizon.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_for_child_respects_horizon_days(tmp_path) -> None:
|
||||||
|
"""A non-default horizon_days sizes the child's rebuild window."""
|
||||||
|
async def _body(database):
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
child = await children.create("Ada", NOW)
|
||||||
|
|
||||||
|
today = datetime.date.today()
|
||||||
|
start_iso = today.isoformat()
|
||||||
|
|
||||||
|
await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Daily chore",
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
|
||||||
|
[child.id],
|
||||||
|
[("morning", "09:00")],
|
||||||
|
)
|
||||||
|
|
||||||
|
await regenerate_for_child(database, child.id, horizon_days=5)
|
||||||
|
|
||||||
|
dao = QuestInstancesDao(database)
|
||||||
|
horizon_end = today + datetime.timedelta(days=5)
|
||||||
|
records = await dao.list_by_date_range(
|
||||||
|
child.id, start_iso, horizon_end.isoformat()
|
||||||
|
)
|
||||||
|
assert len(records) == 6
|
||||||
|
|
||||||
|
# Bounded window: nothing must exist at day 6 or later.
|
||||||
|
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||||
|
beyond_end = (
|
||||||
|
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
assert await dao.list_by_date_range(child.id, beyond, beyond_end) == []
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "regenerate-child-horizon.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [-1, 0, True, False, "5", 1.5])
|
||||||
|
def test_regenerate_for_definition_rejects_invalid_horizon_before_delete(
|
||||||
|
tmp_path, bad
|
||||||
|
) -> None:
|
||||||
|
"""A malformed horizon fails BEFORE any future-instance delete."""
|
||||||
|
async def _body(database):
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
child = await children.create("Ada", NOW)
|
||||||
|
|
||||||
|
today = datetime.date.today()
|
||||||
|
start_iso = today.isoformat()
|
||||||
|
horizon_end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
|
||||||
|
created = await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Daily chore",
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
|
||||||
|
[child.id],
|
||||||
|
[("morning", "09:00")],
|
||||||
|
)
|
||||||
|
dao = QuestInstancesDao(database)
|
||||||
|
await materialize(database, start_iso, horizon_end.isoformat())
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="horizon_days"):
|
||||||
|
await regenerate_for_definition(
|
||||||
|
database, created.definition.id, horizon_days=bad
|
||||||
|
)
|
||||||
|
|
||||||
|
# The refused call deleted nothing: the seeded horizon survives.
|
||||||
|
records = await dao.list_by_date_range(
|
||||||
|
child.id, start_iso, horizon_end.isoformat()
|
||||||
|
)
|
||||||
|
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "regenerate-def-horizon-invalid.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [-1, 0, True, False, "5", 1.5])
|
||||||
|
def test_regenerate_for_child_rejects_invalid_horizon_before_delete(
|
||||||
|
tmp_path, bad
|
||||||
|
) -> None:
|
||||||
|
"""A malformed child horizon fails BEFORE any future-instance delete."""
|
||||||
|
async def _body(database):
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
child = await children.create("Ada", NOW)
|
||||||
|
|
||||||
|
today = datetime.date.today()
|
||||||
|
start_iso = today.isoformat()
|
||||||
|
horizon_end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
|
||||||
|
await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Daily chore",
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
|
||||||
|
[child.id],
|
||||||
|
[("morning", "09:00")],
|
||||||
|
)
|
||||||
|
dao = QuestInstancesDao(database)
|
||||||
|
await materialize(database, start_iso, horizon_end.isoformat())
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="horizon_days"):
|
||||||
|
await regenerate_for_child(database, child.id, horizon_days=bad)
|
||||||
|
|
||||||
|
records = await dao.list_by_date_range(
|
||||||
|
child.id, start_iso, horizon_end.isoformat()
|
||||||
|
)
|
||||||
|
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "regenerate-child-horizon-invalid.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
#: Fixed end-to-end anchor: 2026-06-01 is a Monday AND the 1st of its month,
|
#: Fixed end-to-end anchor: 2026-06-01 is a Monday AND the 1st of its month,
|
||||||
#: so the two-week custody cycle is week-aligned and the yearly rule's
|
#: so the two-week custody cycle is week-aligned and the yearly rule's
|
||||||
#: "1st of the anchor month" is GUARANTEED to fire inside the 28-day window
|
#: "1st of the anchor month" is GUARANTEED to fire inside the 28-day window
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import re
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.dao_instances import (
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
@@ -1938,3 +1939,71 @@ def test_full_lifecycle_preserves_completed_and_history(tmp_path) -> None:
|
|||||||
return reactivated
|
return reactivated
|
||||||
|
|
||||||
_with_db(tmp_path, "full-lifecycle.db")(_body)
|
_with_db(tmp_path, "full-lifecycle.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"mode",
|
||||||
|
["edit", "assign", "unassign", "set_active"],
|
||||||
|
)
|
||||||
|
def test_wrappers_forward_horizon_days(tmp_path, mode) -> None:
|
||||||
|
"""The four change wrappers forward a non-default horizon_days.
|
||||||
|
|
||||||
|
Each wrapper threads ``horizon_days`` into the regeneration it triggers;
|
||||||
|
passing 5 here must bound the regenerated window to today..today+5 —
|
||||||
|
proving the forwarding argument is actually honored, not dropped.
|
||||||
|
"""
|
||||||
|
async def _body(database, children, rules, definitions, child):
|
||||||
|
created = await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Chore",
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, start_date=_today_iso()),
|
||||||
|
[child.id],
|
||||||
|
["morning"],
|
||||||
|
)
|
||||||
|
definition_id = created.definition.id
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
|
||||||
|
if mode == "edit":
|
||||||
|
await edit_quest_definition(
|
||||||
|
database,
|
||||||
|
definition_id,
|
||||||
|
windows=[("morning", "10:30")],
|
||||||
|
horizon_days=5,
|
||||||
|
)
|
||||||
|
tracked = child
|
||||||
|
elif mode == "assign":
|
||||||
|
bo = await children.create("Bo", NOW)
|
||||||
|
await assign_child(database, definition_id, bo.id, horizon_days=5)
|
||||||
|
tracked = bo
|
||||||
|
elif mode == "unassign":
|
||||||
|
# The remaining assignee (``child``) must be regenerated over
|
||||||
|
# the 5-day window after ``bo`` is removed.
|
||||||
|
bo = await children.create("Bo", NOW)
|
||||||
|
await assign_child(database, definition_id, bo.id)
|
||||||
|
await unassign_child(
|
||||||
|
database, definition_id, bo.id, horizon_days=5
|
||||||
|
)
|
||||||
|
tracked = child
|
||||||
|
else: # set_active
|
||||||
|
await set_quest_definition_active(
|
||||||
|
database, definition_id, True, horizon_days=5
|
||||||
|
)
|
||||||
|
tracked = child
|
||||||
|
|
||||||
|
today = datetime.date.today()
|
||||||
|
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||||
|
records = await instances.list_by_date_range(
|
||||||
|
tracked.id, today.isoformat(), end
|
||||||
|
)
|
||||||
|
assert len(records) == 6
|
||||||
|
|
||||||
|
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
||||||
|
beyond_end = (
|
||||||
|
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
assert await instances.list_by_date_range(
|
||||||
|
tracked.id, beyond, beyond_end
|
||||||
|
) == []
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, f"wrapper-horizon-{mode}.db")(_body)
|
||||||
|
|||||||
@@ -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