Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b074a32c16 | ||
|
|
5e951935d9 |
@@ -24,11 +24,11 @@ from .const import (
|
|||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
|
SERVICE_REGENERATE,
|
||||||
)
|
)
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
from .materialize import materialize as _materialize_run
|
from .materialize import materialize as _materialize_run
|
||||||
from .migrations import apply_migrations
|
from .migrations import apply_migrations
|
||||||
from .services import async_deregister_services, async_register_services
|
|
||||||
from .store import async_get_db_path
|
from .store import async_get_db_path
|
||||||
from .admin_allowlist import seed_setup_admin
|
from .admin_allowlist import seed_setup_admin
|
||||||
|
|
||||||
@@ -356,8 +356,21 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _make_regenerate_handler(hass: HomeAssistant):
|
def _register_regenerate_service(hass: HomeAssistant) -> None:
|
||||||
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
"""Register ``nestquest.regenerate`` once at domain scope.
|
||||||
|
|
||||||
|
The service triggers a full on-demand regeneration over the rolling
|
||||||
|
horizon, running the SAME idempotent materialize path
|
||||||
|
(:func:`_run_horizon_materialization`) as the startup backfill and the
|
||||||
|
daily rollover listener. It is DOMAIN-global — registered exactly once
|
||||||
|
(guarded by ``has_service``) and NOT bound to any config entry — so the
|
||||||
|
handler resolves the currently-live database at call time and a name
|
||||||
|
collision or duplicate setup is impossible. Removal is handled by the
|
||||||
|
unload path only once the LAST entry is removed (see
|
||||||
|
:func:`_deregister_regenerate_service`).
|
||||||
|
"""
|
||||||
|
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||||
|
return
|
||||||
|
|
||||||
async def _regenerate(_call: Any) -> None:
|
async def _regenerate(_call: Any) -> None:
|
||||||
runtime_data = _find_live_runtime_data(hass)
|
runtime_data = _find_live_runtime_data(hass)
|
||||||
@@ -372,29 +385,13 @@ def _make_regenerate_handler(hass: HomeAssistant):
|
|||||||
hass, runtime_data.database, horizon_days
|
hass, runtime_data.database, horizon_days
|
||||||
)
|
)
|
||||||
|
|
||||||
return _regenerate
|
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
|
||||||
|
|
||||||
|
|
||||||
def _register_services(hass: HomeAssistant) -> None:
|
def _deregister_regenerate_service(hass: HomeAssistant) -> None:
|
||||||
"""Register domain-global NestQuest services once.
|
"""Remove ``nestquest.regenerate`` when the last entry unloads."""
|
||||||
|
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||||
Services are DOMAIN-global — registered exactly once (guarded by
|
hass.services.async_remove(DOMAIN, SERVICE_REGENERATE)
|
||||||
``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:
|
||||||
@@ -492,7 +489,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await _run_horizon_materialization(
|
await _run_horizon_materialization(
|
||||||
hass, database, _configured_horizon_days(dict(entry.options))
|
hass, database, _configured_horizon_days(dict(entry.options))
|
||||||
)
|
)
|
||||||
_register_services(hass)
|
_register_regenerate_service(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:
|
||||||
@@ -565,11 +562,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()
|
||||||
# Domain-global services are torn down only once the FINAL entry
|
# The ``regenerate`` service is DOMAIN-global: it is torn down only
|
||||||
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
# once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty),
|
||||||
# entry is still loaded.
|
# never when a sibling entry is still loaded.
|
||||||
if is_last:
|
if is_last:
|
||||||
_deregister_services(hass)
|
_deregister_regenerate_service(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:
|
||||||
|
|||||||
@@ -6,12 +6,9 @@ validation and the UTC occurred-at stamp here, and never talk to the
|
|||||||
DAO or SQL directly. This module is the only Feature-08 caller of
|
DAO or SQL directly. This module is the only Feature-08 caller of
|
||||||
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
||||||
instance (no-op if already done) is :func:`complete_instance`;
|
instance (no-op if already done) is :func:`complete_instance`;
|
||||||
un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
un-completing arrives in a later task. Instance current state is
|
||||||
Instance current state is derived from the latest event via
|
derived from the latest event via :func:`instance_state`
|
||||||
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
|
(``open`` / ``done``); there is no status column.
|
||||||
no status column. ``missed`` is derived for past-due open instances
|
|
||||||
(``due_date`` before HA-local ``today``) and is never stored as an
|
|
||||||
event_type.
|
|
||||||
|
|
||||||
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
||||||
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
||||||
@@ -37,7 +34,6 @@ from .dao_instances import (
|
|||||||
EVENT_UNCOMPLETED,
|
EVENT_UNCOMPLETED,
|
||||||
CompletionEventRecord,
|
CompletionEventRecord,
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
QuestInstanceRecord,
|
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
)
|
)
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
@@ -67,95 +63,6 @@ def _stamp_occurred_at(now: datetime.datetime | None) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _completion_moment(
|
|
||||||
now: datetime.datetime | None,
|
|
||||||
today: datetime.date | None,
|
|
||||||
) -> tuple[datetime.date, datetime.time | None]:
|
|
||||||
"""Return the HA-local completion date and optional wall-clock time.
|
|
||||||
|
|
||||||
``today`` is the HA-local calendar date when threaded (Feature 07
|
|
||||||
style). ``now`` is a timezone-aware HA-local datetime; its wall
|
|
||||||
clock is used as-is and is never converted to UTC. When ``today``
|
|
||||||
is omitted the date comes from ``now``. When both are omitted the
|
|
||||||
host calendar date is the Feature 07 fallback — never the UTC
|
|
||||||
clock. Time-of-day is None unless ``now`` is supplied; comparing
|
|
||||||
against ``due_time`` requires ``now``.
|
|
||||||
"""
|
|
||||||
if now is not None:
|
|
||||||
if not isinstance(now, datetime.datetime):
|
|
||||||
raise ValueError(f"now must be a datetime, got {now!r}")
|
|
||||||
if now.tzinfo is None:
|
|
||||||
raise ValueError("now must be timezone-aware")
|
|
||||||
if today is not None:
|
|
||||||
completion_date = today
|
|
||||||
elif now is not None:
|
|
||||||
completion_date = now.date()
|
|
||||||
else:
|
|
||||||
completion_date = datetime.date.today()
|
|
||||||
if now is None:
|
|
||||||
return completion_date, None
|
|
||||||
return completion_date, now.time()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
|
||||||
"""Return the threaded HA-local date, or the host calendar date."""
|
|
||||||
if today is None:
|
|
||||||
return datetime.date.today()
|
|
||||||
return today
|
|
||||||
|
|
||||||
|
|
||||||
def _derived_state(
|
|
||||||
instance: QuestInstanceRecord,
|
|
||||||
latest: CompletionEventRecord | None,
|
|
||||||
today: datetime.date,
|
|
||||||
) -> str:
|
|
||||||
"""Return ``open``, ``done``, or ``missed`` from event plus due date.
|
|
||||||
|
|
||||||
A latest completed event is ``done`` even when past due. No events
|
|
||||||
or a latest uncompleted event is ``open``, or ``missed`` when
|
|
||||||
``due_date`` is before ``today``. ``missed`` is never an event_type.
|
|
||||||
"""
|
|
||||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
|
||||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
if due_date < today:
|
|
||||||
return "missed"
|
|
||||||
return "open"
|
|
||||||
if latest.event_type == EVENT_COMPLETED:
|
|
||||||
return "done"
|
|
||||||
raise ValueError(
|
|
||||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
|
||||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _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:
|
class _TaskReentrantLock:
|
||||||
"""Shared connection lock the owning task may re-enter."""
|
"""Shared connection lock the owning task may re-enter."""
|
||||||
|
|
||||||
@@ -197,10 +104,9 @@ def _reentrant_connection_lock(database) -> _TaskReentrantLock:
|
|||||||
"""Return the shared connection lock, made task-reentrant.
|
"""Return the shared connection lock, made task-reentrant.
|
||||||
|
|
||||||
``CompletionEventsDao.append`` also acquires this lock, so
|
``CompletionEventsDao.append`` also acquires this lock, so
|
||||||
check+append in :func:`complete_instance` and
|
check+append in :func:`complete_instance` must re-enter on the
|
||||||
:func:`uncomplete_instance` must re-enter on the same task. The
|
same task. The wrapper is stored in the shared dict so the DAO
|
||||||
wrapper is stored in the shared dict so the DAO sees the same
|
sees the same object.
|
||||||
object.
|
|
||||||
"""
|
"""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
key = (id(database), id(loop))
|
key = (id(database), id(loop))
|
||||||
@@ -324,21 +230,15 @@ async def append_event(
|
|||||||
async def instance_state(
|
async def instance_state(
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
instance_id: int,
|
instance_id: int,
|
||||||
*,
|
|
||||||
today: datetime.date | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return ``open``, ``done``, or ``missed`` from the latest event.
|
"""Return ``open`` or ``done`` from the instance's latest event.
|
||||||
|
|
||||||
No events, or a latest event of uncompleted, is ``open`` unless
|
No events, or a latest event of uncompleted, is ``open``. A latest
|
||||||
``due_date`` is before ``today`` (HA-local, threaded), in which
|
event of completed is ``done``. Unknown ``instance_id`` raises
|
||||||
case it is ``missed``. A latest event of completed is ``done``
|
ValueError naming the field. Existence and the latest event are
|
||||||
even when past due. ``missed`` is never stored as an event_type.
|
read inside the connection lock so the answer is one snapshot.
|
||||||
Unknown ``instance_id`` raises ValueError naming the field.
|
|
||||||
Existence and the latest event are read inside the connection lock
|
|
||||||
so the answer is one snapshot.
|
|
||||||
"""
|
"""
|
||||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||||
today_date = _resolve_today(today)
|
|
||||||
async with _connection_lock(database):
|
async with _connection_lock(database):
|
||||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||||
if instance is None:
|
if instance is None:
|
||||||
@@ -348,7 +248,14 @@ async def instance_state(
|
|||||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||||
instance_id
|
instance_id
|
||||||
)
|
)
|
||||||
return _derived_state(instance, latest, today_date)
|
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||||
|
return "open"
|
||||||
|
if latest.event_type == EVENT_COMPLETED:
|
||||||
|
return "done"
|
||||||
|
raise ValueError(
|
||||||
|
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||||
|
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def complete_instance(
|
async def complete_instance(
|
||||||
@@ -369,11 +276,11 @@ async def complete_instance(
|
|||||||
append run under one connection lock so a concurrent complete
|
append run under one connection lock so a concurrent complete
|
||||||
cannot double-append.
|
cannot double-append.
|
||||||
|
|
||||||
``was_on_time`` is computed from the HA-local completion moment
|
``was_on_time`` is recorded as True — a placeholder until the
|
||||||
(threaded ``now`` / ``today``) against the instance ``due_date``
|
on-time task computes it from ``now`` / ``today`` against the
|
||||||
and optional ``due_time``. ``now`` is the timezone-aware HA-local
|
instance due_date and optional due_time. ``today`` is accepted so
|
||||||
datetime; its wall clock is never converted to UTC. Same-day
|
callers can thread the HA-local date through without a signature
|
||||||
``due_time`` comparison requires ``now``.
|
change; it is unused here.
|
||||||
"""
|
"""
|
||||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
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 = _validate_actor(
|
||||||
@@ -403,93 +310,7 @@ async def complete_instance(
|
|||||||
actor_source=actor_source,
|
actor_source=actor_source,
|
||||||
actor_user_id=actor_user_id,
|
actor_user_id=actor_user_id,
|
||||||
actor_child_id=actor_child_id,
|
actor_child_id=actor_child_id,
|
||||||
was_on_time=_was_on_time(instance, now, today),
|
was_on_time=True,
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
return "done"
|
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,34 +9,9 @@ LOGGER = logging.getLogger(LOGGER_NAME)
|
|||||||
|
|
||||||
PLATFORMS: list[str] = []
|
PLATFORMS: list[str] = []
|
||||||
|
|
||||||
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
#: Service name for the on-demand regeneration service (``nestquest.regenerate``).
|
||||||
#: plus the existing Feature 07 regenerate service).
|
|
||||||
SERVICE_COMPLETE_QUEST = "complete_quest"
|
|
||||||
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
|
|
||||||
SERVICE_MANAGE_CHILD = "manage_child"
|
|
||||||
SERVICE_REGENERATE = "regenerate"
|
SERVICE_REGENERATE = "regenerate"
|
||||||
|
|
||||||
DOMAIN_SERVICES: tuple[str, ...] = (
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
SERVICE_REGENERATE,
|
|
||||||
)
|
|
||||||
|
|
||||||
SQLITE_DB_FILENAME = "nestquest.db"
|
SQLITE_DB_FILENAME = "nestquest.db"
|
||||||
|
|
||||||
# Configuration keys and default values
|
# Configuration keys and default values
|
||||||
|
|||||||
@@ -270,15 +270,6 @@ 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(
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
"""Permission gate for NestQuest services (Feature 09).
|
|
||||||
|
|
||||||
A wrapper factory, applied to EVERY registered service at registration
|
|
||||||
time (no handler is exempt), reads ``call.context.user_id``, consults
|
|
||||||
the :data:`~.service_policy.SERVICE_POLICY` registry and
|
|
||||||
:func:`~.admin_allowlist.is_admin`, and raises
|
|
||||||
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
|
|
||||||
operation called by a non-admin — including a call with NO user
|
|
||||||
context, which fails closed. ``complete_quest`` (policy ``open``)
|
|
||||||
proceeds regardless of the allowlist.
|
|
||||||
|
|
||||||
Logging (later Feature 09 tasks): every denied admin-only call logs a
|
|
||||||
warning and every successful one logs a debug line, both carrying the
|
|
||||||
service, the user id (or ``none``), and a UTC timestamp.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from homeassistant.exceptions import Unauthorized
|
|
||||||
|
|
||||||
from .admin_allowlist import is_admin
|
|
||||||
from .service_policy import ADMIN_ONLY
|
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
|
||||||
|
|
||||||
|
|
||||||
def _context_user_id(call: Any) -> str | None:
|
|
||||||
context = getattr(call, "context", None)
|
|
||||||
user_id = getattr(context, "user_id", None) if context is not None else None
|
|
||||||
if isinstance(user_id, str) and user_id:
|
|
||||||
return user_id
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _stamp() -> str:
|
|
||||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
|
||||||
timespec="seconds"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def permission_gate(
|
|
||||||
service: str,
|
|
||||||
policy: str,
|
|
||||||
database_of: Callable[[Any], Any],
|
|
||||||
) -> Callable[[ServiceHandler], ServiceHandler]:
|
|
||||||
"""Wrap ``handler`` with the Feature 09 permission check.
|
|
||||||
|
|
||||||
``database_of`` resolves the live database from the call-time
|
|
||||||
runtime (the same ``find_runtime`` the handler uses), so the gate
|
|
||||||
never opens its own connection. The verdict is fail-closed:
|
|
||||||
absent allowlist row, unknown id, no user context — all not admin.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _wrap(handler: ServiceHandler) -> ServiceHandler:
|
|
||||||
if policy != ADMIN_ONLY:
|
|
||||||
return handler
|
|
||||||
|
|
||||||
async def _gated(call: Any) -> None:
|
|
||||||
user_id = _context_user_id(call)
|
|
||||||
allowed = user_id is not None and await is_admin(
|
|
||||||
database_of(call), user_id
|
|
||||||
)
|
|
||||||
if not allowed:
|
|
||||||
LOGGER.warning(
|
|
||||||
"NestQuest denied %s: caller user_id=%s at %s",
|
|
||||||
service,
|
|
||||||
user_id if user_id is not None else "none",
|
|
||||||
_stamp(),
|
|
||||||
)
|
|
||||||
raise Unauthorized(
|
|
||||||
f"User '{user_id if user_id is not None else 'none'}' "
|
|
||||||
f"is not allowed to call nestquest.{service}"
|
|
||||||
)
|
|
||||||
LOGGER.debug(
|
|
||||||
"NestQuest allowed %s: caller user_id=%s at %s",
|
|
||||||
service,
|
|
||||||
user_id,
|
|
||||||
_stamp(),
|
|
||||||
)
|
|
||||||
await handler(call)
|
|
||||||
|
|
||||||
return _gated
|
|
||||||
|
|
||||||
return _wrap
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""Explicit admin-only versus open service registry (Feature 09).
|
|
||||||
|
|
||||||
One mapping, one file: every registered ``nestquest`` service name is
|
|
||||||
either ``open`` or ``admin_only``. The permission decorator (later
|
|
||||||
Feature 09 tasks) reads this table; it does not take per-handler
|
|
||||||
policy arguments.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .const import (
|
|
||||||
DOMAIN_SERVICES,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
SERVICE_REGENERATE,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
|
||||||
)
|
|
||||||
|
|
||||||
OPEN = "open"
|
|
||||||
ADMIN_ONLY = "admin_only"
|
|
||||||
|
|
||||||
SERVICE_POLICY: dict[str, str] = {
|
|
||||||
SERVICE_COMPLETE_QUEST: OPEN,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
|
|
||||||
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
|
|
||||||
SERVICE_REGENERATE: ADMIN_ONLY,
|
|
||||||
}
|
|
||||||
|
|
||||||
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
|
|
||||||
raise RuntimeError(
|
|
||||||
"SERVICE_POLICY must name exactly the registered nestquest services, "
|
|
||||||
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
|
|
||||||
)
|
|
||||||
@@ -1,604 +0,0 @@
|
|||||||
"""Domain-global NestQuest service schemas and handlers (Feature 09).
|
|
||||||
|
|
||||||
Registration is domain-scoped and happens once (see
|
|
||||||
:func:`async_register_services`); handlers resolve a live database at
|
|
||||||
call time through the injected ``find_runtime`` callable. Permission
|
|
||||||
checks are Feature 09 later tasks — this module only validates payload
|
|
||||||
shape and forwards to the Feature 03/06/08 business layers (and the
|
|
||||||
presence DAOs). ``export_history_csv`` is registered with a schema but
|
|
||||||
raises until Feature 13. ``regenerate`` is supplied by the caller so
|
|
||||||
the Feature 07 handler stays the one in ``__init__``.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import Any
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
|
|
||||||
from . import children as children_layer
|
|
||||||
from . import completion as completion_layer
|
|
||||||
from . import quest_definitions as quest_definitions_layer
|
|
||||||
from .const import (
|
|
||||||
CONF_HORIZON_DAYS,
|
|
||||||
DEFAULT_HORIZON_DAYS,
|
|
||||||
DOMAIN,
|
|
||||||
DOMAIN_SERVICES,
|
|
||||||
QUEST_WINDOWS,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
SERVICE_REGENERATE,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
|
||||||
)
|
|
||||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
|
||||||
from .materialize import regenerate_for_child
|
|
||||||
from .permissions import permission_gate
|
|
||||||
from .presence import PresenceSchedule
|
|
||||||
from .recurrence import ScheduleRule
|
|
||||||
from .service_policy import SERVICE_POLICY
|
|
||||||
|
|
||||||
FindRuntime = Callable[[HomeAssistant], Any]
|
|
||||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
|
||||||
|
|
||||||
ACTOR_PANEL = "panel"
|
|
||||||
ACTOR_USER = "user"
|
|
||||||
MANAGE_CHILD_CREATE = "create"
|
|
||||||
MANAGE_CHILD_EDIT = "edit"
|
|
||||||
MANAGE_CHILD_SET_ACTIVE = "set_active"
|
|
||||||
MANAGE_CHILD_REORDER = "reorder"
|
|
||||||
|
|
||||||
|
|
||||||
def _strict_int(value: object) -> int:
|
|
||||||
"""Reject bools and non-ints; voluptuous ``int`` would accept True."""
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int):
|
|
||||||
raise vol.Invalid(f"expected int, got {value!r}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _strict_bool(value: object) -> bool:
|
|
||||||
"""Reject ints and other truthy values; require a real bool."""
|
|
||||||
if type(value) is not bool:
|
|
||||||
raise vol.Invalid(f"expected bool, got {value!r}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _weekday_list(value: object) -> list[int]:
|
|
||||||
if type(value) is not list:
|
|
||||||
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
|
|
||||||
return [_strict_int(item) for item in value]
|
|
||||||
|
|
||||||
|
|
||||||
def _presence_pattern(value: object) -> dict[int, list[int]]:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
|
|
||||||
pattern: dict[int, list[int]] = {}
|
|
||||||
for week, days in value.items():
|
|
||||||
if isinstance(week, str):
|
|
||||||
try:
|
|
||||||
week_index = int(week)
|
|
||||||
except ValueError as err:
|
|
||||||
raise vol.Invalid(
|
|
||||||
f"pattern week index must be an integer, got {week!r}"
|
|
||||||
) from err
|
|
||||||
if str(week_index) != week:
|
|
||||||
raise vol.Invalid(
|
|
||||||
f"pattern week index must be an integer, got {week!r}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
week_index = _strict_int(week)
|
|
||||||
pattern[week_index] = _weekday_list(days)
|
|
||||||
return pattern
|
|
||||||
|
|
||||||
|
|
||||||
def _window_entry(value: object) -> str | tuple[str, str | None]:
|
|
||||||
if isinstance(value, str):
|
|
||||||
if value not in QUEST_WINDOWS:
|
|
||||||
raise vol.Invalid(f"unknown window {value!r}")
|
|
||||||
return value
|
|
||||||
if isinstance(value, dict):
|
|
||||||
parsed = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("window"): vol.In(QUEST_WINDOWS),
|
|
||||||
vol.Optional("due_time"): str,
|
|
||||||
}
|
|
||||||
)(value)
|
|
||||||
due_time = parsed.get("due_time")
|
|
||||||
if due_time is None:
|
|
||||||
return parsed["window"]
|
|
||||||
return (parsed["window"], due_time)
|
|
||||||
raise vol.Invalid(
|
|
||||||
f"windows entry must be a window name or object, got {value!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _windows(value: object) -> list[str | tuple[str, str | None]]:
|
|
||||||
if type(value) is not list:
|
|
||||||
raise vol.Invalid(f"windows must be a list, got {value!r}")
|
|
||||||
if not value:
|
|
||||||
raise vol.Invalid("windows must not be empty")
|
|
||||||
return [_window_entry(entry) for entry in value]
|
|
||||||
|
|
||||||
|
|
||||||
def _int_list(value: object) -> list[int]:
|
|
||||||
if type(value) is not list:
|
|
||||||
raise vol.Invalid(f"expected a list of ints, got {value!r}")
|
|
||||||
return [_strict_int(item) for item in value]
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_dict(value: object) -> dict:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise vol.Invalid(f"rule must be a dict, got {value!r}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
SCHEMA_COMPLETE_QUEST = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("instance_id"): _strict_int,
|
|
||||||
vol.Required("actor"): vol.In([ACTOR_PANEL, ACTOR_USER]),
|
|
||||||
vol.Optional("actor_child_id"): _strict_int,
|
|
||||||
vol.Optional("actor_user_id"): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
|
|
||||||
|
|
||||||
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("title"): str,
|
|
||||||
vol.Required("rule"): _rule_dict,
|
|
||||||
vol.Required("assignee_child_ids"): vol.All(
|
|
||||||
_int_list, vol.Length(min=1)
|
|
||||||
),
|
|
||||||
vol.Required("windows"): _windows,
|
|
||||||
vol.Optional("description"): str,
|
|
||||||
vol.Optional("icon"): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("definition_id"): _strict_int,
|
|
||||||
vol.Optional("title"): str,
|
|
||||||
vol.Optional("description"): vol.Any(str, None),
|
|
||||||
vol.Optional("icon"): vol.Any(str, None),
|
|
||||||
vol.Optional("rule"): _rule_dict,
|
|
||||||
vol.Optional("windows"): _windows,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("definition_id"): _strict_int,
|
|
||||||
vol.Required("is_active"): _strict_bool,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("child_id"): _strict_int,
|
|
||||||
vol.Required("cycle_length_weeks"): _strict_int,
|
|
||||||
vol.Required("anchor_date"): str,
|
|
||||||
vol.Required("pattern"): _presence_pattern,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("child_id"): _strict_int,
|
|
||||||
vol.Required("start_date"): str,
|
|
||||||
vol.Required("end_date"): str,
|
|
||||||
vol.Required("is_present"): _strict_bool,
|
|
||||||
vol.Optional("note"): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("override_id"): _strict_int,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
|
|
||||||
|
|
||||||
SCHEMA_MANAGE_CHILD = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required("action"): vol.In(
|
|
||||||
[
|
|
||||||
MANAGE_CHILD_CREATE,
|
|
||||||
MANAGE_CHILD_EDIT,
|
|
||||||
MANAGE_CHILD_SET_ACTIVE,
|
|
||||||
MANAGE_CHILD_REORDER,
|
|
||||||
]
|
|
||||||
),
|
|
||||||
vol.Optional("child_id"): _strict_int,
|
|
||||||
vol.Optional("display_name"): str,
|
|
||||||
vol.Optional("colour"): str,
|
|
||||||
vol.Optional("avatar_ref"): str,
|
|
||||||
vol.Optional("sort_order"): _strict_int,
|
|
||||||
vol.Optional("is_active"): _strict_bool,
|
|
||||||
vol.Optional("ordered_ids"): _int_list,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
|
||||||
runtime = find_runtime(hass)
|
|
||||||
if runtime is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"NestQuest has no live database; cannot run this service"
|
|
||||||
)
|
|
||||||
return runtime
|
|
||||||
|
|
||||||
|
|
||||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
|
||||||
time_zone = ZoneInfo(hass.config.time_zone)
|
|
||||||
today = datetime.datetime.now(time_zone).date()
|
|
||||||
value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
||||||
horizon_days = DEFAULT_HORIZON_DAYS
|
|
||||||
else:
|
|
||||||
horizon_days = value
|
|
||||||
return {"today": today, "horizon_days": horizon_days}
|
|
||||||
|
|
||||||
|
|
||||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
|
||||||
actor = call.data["actor"]
|
|
||||||
if actor == ACTOR_PANEL:
|
|
||||||
actor_child_id = call.data.get("actor_child_id")
|
|
||||||
if actor_child_id is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"actor_child_id is required when actor is panel"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"actor_source": ACTOR_PANEL,
|
|
||||||
"actor_child_id": actor_child_id,
|
|
||||||
"actor_user_id": None,
|
|
||||||
}
|
|
||||||
context = getattr(call, "context", None)
|
|
||||||
user_id = getattr(context, "user_id", None) if context is not None else None
|
|
||||||
if not user_id:
|
|
||||||
user_id = call.data.get("actor_user_id")
|
|
||||||
return {
|
|
||||||
"actor_source": ACTOR_USER,
|
|
||||||
"actor_user_id": user_id,
|
|
||||||
"actor_child_id": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _with_errors(handler: ServiceHandler) -> ServiceHandler:
|
|
||||||
async def _wrapped(call: Any) -> None:
|
|
||||||
try:
|
|
||||||
await handler(call)
|
|
||||||
except HomeAssistantError:
|
|
||||||
raise
|
|
||||||
except ValueError as err:
|
|
||||||
raise HomeAssistantError(str(err)) from err
|
|
||||||
|
|
||||||
return _wrapped
|
|
||||||
|
|
||||||
|
|
||||||
def _complete_quest(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
database = _require_runtime(hass, find_runtime).database
|
|
||||||
await completion_layer.complete_instance(
|
|
||||||
database,
|
|
||||||
call.data["instance_id"],
|
|
||||||
**_actor_kwargs(call),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _uncomplete_quest(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
database = _require_runtime(hass, find_runtime).database
|
|
||||||
await completion_layer.uncomplete_instance(
|
|
||||||
database,
|
|
||||||
call.data["instance_id"],
|
|
||||||
**_actor_kwargs(call),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_quest_definition(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
database = _require_runtime(hass, find_runtime).database
|
|
||||||
data = call.data
|
|
||||||
await quest_definitions_layer.create_quest_definition(
|
|
||||||
database,
|
|
||||||
data["title"],
|
|
||||||
ScheduleRule.from_dict(data["rule"]),
|
|
||||||
data["assignee_child_ids"],
|
|
||||||
data["windows"],
|
|
||||||
description=data.get("description"),
|
|
||||||
icon=data.get("icon"),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _update_quest_definition(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
data = call.data
|
|
||||||
kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
|
|
||||||
if "title" in data:
|
|
||||||
kwargs["title"] = data["title"]
|
|
||||||
if "description" in data:
|
|
||||||
kwargs["description"] = data["description"]
|
|
||||||
if "icon" in data:
|
|
||||||
kwargs["icon"] = data["icon"]
|
|
||||||
if "rule" in data:
|
|
||||||
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
|
|
||||||
if "windows" in data:
|
|
||||||
kwargs["windows"] = data["windows"]
|
|
||||||
await quest_definitions_layer.edit_quest_definition(
|
|
||||||
runtime.database, data["definition_id"], **kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_quest_definition_active(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
await quest_definitions_layer.set_quest_definition_active(
|
|
||||||
runtime.database,
|
|
||||||
call.data["definition_id"],
|
|
||||||
call.data["is_active"],
|
|
||||||
**_generation_kwargs(hass, runtime),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_presence_pattern(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
data = call.data
|
|
||||||
schedule = PresenceSchedule(
|
|
||||||
data["child_id"],
|
|
||||||
data["cycle_length_weeks"],
|
|
||||||
data["anchor_date"],
|
|
||||||
data["pattern"],
|
|
||||||
)
|
|
||||||
await PresenceSchedulesDao(runtime.database).upsert_by_child(
|
|
||||||
schedule.child_id,
|
|
||||||
schedule.cycle_length_weeks,
|
|
||||||
schedule.anchor_date.isoformat(),
|
|
||||||
schedule.encode(),
|
|
||||||
)
|
|
||||||
await regenerate_for_child(
|
|
||||||
runtime.database,
|
|
||||||
schedule.child_id,
|
|
||||||
**_generation_kwargs(hass, runtime),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_presence_override(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
data = call.data
|
|
||||||
await PresenceOverridesDao(runtime.database).create(
|
|
||||||
data["child_id"],
|
|
||||||
data["start_date"],
|
|
||||||
data["end_date"],
|
|
||||||
data["is_present"],
|
|
||||||
note=data.get("note"),
|
|
||||||
)
|
|
||||||
await regenerate_for_child(
|
|
||||||
runtime.database,
|
|
||||||
data["child_id"],
|
|
||||||
**_generation_kwargs(hass, runtime),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_presence_override(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
dao = PresenceOverridesDao(runtime.database)
|
|
||||||
override_id = call.data["override_id"]
|
|
||||||
override = await dao.get(override_id)
|
|
||||||
if override is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
f"override_id: presence override {override_id} does not exist"
|
|
||||||
)
|
|
||||||
await dao.delete(override_id)
|
|
||||||
await regenerate_for_child(
|
|
||||||
runtime.database,
|
|
||||||
override.child_id,
|
|
||||||
**_generation_kwargs(hass, runtime),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
async def _export_history_csv(_call: Any) -> None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"CSV history export is not implemented until Feature 13"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _manage_child(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
database = _require_runtime(hass, find_runtime).database
|
|
||||||
data = call.data
|
|
||||||
action = data["action"]
|
|
||||||
if action == MANAGE_CHILD_CREATE:
|
|
||||||
display_name = data.get("display_name")
|
|
||||||
if display_name is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"display_name is required when action is create"
|
|
||||||
)
|
|
||||||
kwargs: dict[str, Any] = {}
|
|
||||||
if "colour" in data:
|
|
||||||
kwargs["colour"] = data["colour"]
|
|
||||||
if "avatar_ref" in data:
|
|
||||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
|
||||||
if "sort_order" in data:
|
|
||||||
kwargs["sort_order"] = data["sort_order"]
|
|
||||||
await children_layer.create_child(
|
|
||||||
database, display_name, **kwargs
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if action == MANAGE_CHILD_EDIT:
|
|
||||||
child_id = data.get("child_id")
|
|
||||||
if child_id is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"child_id is required when action is edit"
|
|
||||||
)
|
|
||||||
kwargs = {}
|
|
||||||
if "display_name" in data:
|
|
||||||
kwargs["display_name"] = data["display_name"]
|
|
||||||
if "colour" in data:
|
|
||||||
kwargs["colour"] = data["colour"]
|
|
||||||
if "avatar_ref" in data:
|
|
||||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
|
||||||
if "sort_order" in data:
|
|
||||||
kwargs["sort_order"] = data["sort_order"]
|
|
||||||
await children_layer.edit_child(database, child_id, **kwargs)
|
|
||||||
return
|
|
||||||
if action == MANAGE_CHILD_SET_ACTIVE:
|
|
||||||
child_id = data.get("child_id")
|
|
||||||
is_active = data.get("is_active")
|
|
||||||
if child_id is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"child_id is required when action is set_active"
|
|
||||||
)
|
|
||||||
if is_active is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"is_active is required when action is set_active"
|
|
||||||
)
|
|
||||||
await children_layer.set_child_active(
|
|
||||||
database, child_id, is_active
|
|
||||||
)
|
|
||||||
return
|
|
||||||
ordered_ids = data.get("ordered_ids")
|
|
||||||
if ordered_ids is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"ordered_ids is required when action is reorder"
|
|
||||||
)
|
|
||||||
await children_layer.reorder_children(database, ordered_ids)
|
|
||||||
|
|
||||||
return _with_errors(_handler)
|
|
||||||
|
|
||||||
|
|
||||||
def async_register_services(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
*,
|
|
||||||
find_runtime: FindRuntime,
|
|
||||||
regenerate_handler: ServiceHandler,
|
|
||||||
) -> None:
|
|
||||||
"""Register every canonical service once at domain scope.
|
|
||||||
|
|
||||||
Every service — regenerate included — passes through the Feature
|
|
||||||
09 permission gate before its handler runs; the gate reads
|
|
||||||
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
|
|
||||||
registered exempt from the policy.
|
|
||||||
"""
|
|
||||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
|
||||||
return
|
|
||||||
gate = permission_gate
|
|
||||||
specs: list[tuple[str, ServiceHandler, object]] = [
|
|
||||||
(SERVICE_REGENERATE, regenerate_handler, None),
|
|
||||||
(
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
_complete_quest(hass, find_runtime),
|
|
||||||
SCHEMA_COMPLETE_QUEST,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
_uncomplete_quest(hass, find_runtime),
|
|
||||||
SCHEMA_UNCOMPLETE_QUEST,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
_create_quest_definition(hass, find_runtime),
|
|
||||||
SCHEMA_CREATE_QUEST_DEFINITION,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
|
||||||
_update_quest_definition(hass, find_runtime),
|
|
||||||
SCHEMA_UPDATE_QUEST_DEFINITION,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
_set_quest_definition_active(hass, find_runtime),
|
|
||||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
_set_presence_pattern(hass, find_runtime),
|
|
||||||
SCHEMA_SET_PRESENCE_PATTERN,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
_create_presence_override(hass, find_runtime),
|
|
||||||
SCHEMA_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
_delete_presence_override(hass, find_runtime),
|
|
||||||
SCHEMA_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV,
|
|
||||||
_export_history_csv,
|
|
||||||
SCHEMA_EXPORT_HISTORY_CSV,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
_manage_child(hass, find_runtime),
|
|
||||||
SCHEMA_MANAGE_CHILD,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def _database_of(_call: Any) -> Any:
|
|
||||||
return _require_runtime(hass, find_runtime).database
|
|
||||||
|
|
||||||
for service, handler, schema in specs:
|
|
||||||
gated = gate(
|
|
||||||
service, SERVICE_POLICY[service], _database_of
|
|
||||||
)(handler)
|
|
||||||
hass.services.async_register(DOMAIN, service, gated, schema=schema)
|
|
||||||
|
|
||||||
|
|
||||||
def async_deregister_services(hass: HomeAssistant) -> None:
|
|
||||||
"""Remove every canonical service when the last entry unloads."""
|
|
||||||
for service in DOMAIN_SERVICES:
|
|
||||||
if hass.services.has_service(DOMAIN, service):
|
|
||||||
hass.services.async_remove(DOMAIN, service)
|
|
||||||
@@ -10,317 +10,3 @@ regenerate:
|
|||||||
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:
|
|
||||||
|
|||||||
+6
-31
@@ -177,11 +177,7 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
|
|||||||
_data_entry_flow_mock.FlowResult = dict
|
_data_entry_flow_mock.FlowResult = dict
|
||||||
|
|
||||||
|
|
||||||
class HomeAssistantError(Exception):
|
class ConfigEntryNotReady(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
|
||||||
@@ -189,13 +185,7 @@ class ConfigEntryNotReady(HomeAssistantError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -335,7 +325,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, schema)
|
self._services[(domain, service)] = func
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def has_service(self, domain, service):
|
def has_service(self, domain, service):
|
||||||
@@ -345,26 +335,11 @@ 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, context=None):
|
async def call(self, domain, service, data=None):
|
||||||
registered = self._services.get((domain, service))
|
func = self._services.get((domain, service))
|
||||||
if registered is None:
|
if func is None:
|
||||||
raise KeyError(f"service {domain}.{service} not registered")
|
raise KeyError(f"service {domain}.{service} not registered")
|
||||||
func, schema = registered
|
call = SimpleNamespace(domain=domain, service=service, data=data or {})
|
||||||
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
|
||||||
|
|||||||
+1
-765
@@ -12,8 +12,6 @@ from custom_components.nestquest.completion import (
|
|||||||
append_event,
|
append_event,
|
||||||
complete_instance,
|
complete_instance,
|
||||||
instance_state,
|
instance_state,
|
||||||
list_missed_for_child,
|
|
||||||
uncomplete_instance,
|
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.dao_instances import (
|
||||||
@@ -340,8 +338,7 @@ def test_module_never_exposes_update_or_delete() -> None:
|
|||||||
assert "append_event" in public
|
assert "append_event" in public
|
||||||
assert "instance_state" in public
|
assert "instance_state" in public
|
||||||
assert "complete_instance" in public
|
assert "complete_instance" in public
|
||||||
assert "uncomplete_instance" in public
|
assert "uncomplete_instance" not in public
|
||||||
assert "list_missed_for_child" in public
|
|
||||||
source = inspect.getsource(completion)
|
source = inspect.getsource(completion)
|
||||||
assert "UPDATE" not in source
|
assert "UPDATE" not in source
|
||||||
assert "DELETE" not in source
|
assert "DELETE" not in source
|
||||||
@@ -687,764 +684,3 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
|||||||
await database.close()
|
await database.close()
|
||||||
|
|
||||||
_run(_main())
|
_run(_main())
|
||||||
|
|
||||||
|
|
||||||
_HA_TZ = datetime.timezone(datetime.timedelta(hours=-8))
|
|
||||||
|
|
||||||
|
|
||||||
def _ha_local(day: datetime.date, hour: int, minute: int = 0) -> datetime.datetime:
|
|
||||||
return datetime.datetime(
|
|
||||||
day.year, day.month, day.day, hour, minute, 0,
|
|
||||||
tzinfo=_HA_TZ,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_on_due_date_before_due_time_is_on_time(
|
|
||||||
tmp_path,
|
|
||||||
) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
instance = await QuestInstancesDao(database).upsert(
|
|
||||||
instance.definition_id,
|
|
||||||
child.id,
|
|
||||||
instance.due_date,
|
|
||||||
_now_stamp(),
|
|
||||||
window=instance.window,
|
|
||||||
due_time="12:00",
|
|
||||||
)
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=_ha_local(due_date, 11, 0),
|
|
||||||
today=due_date,
|
|
||||||
)
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert rows[0].was_on_time is True
|
|
||||||
|
|
||||||
_with_db(tmp_path, "ontime-before-due-time.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_on_due_date_after_due_time_is_late(
|
|
||||||
tmp_path,
|
|
||||||
) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
instance = await QuestInstancesDao(database).upsert(
|
|
||||||
instance.definition_id,
|
|
||||||
child.id,
|
|
||||||
instance.due_date,
|
|
||||||
_now_stamp(),
|
|
||||||
window=instance.window,
|
|
||||||
due_time="12:00",
|
|
||||||
)
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=_ha_local(due_date, 12, 1),
|
|
||||||
today=due_date,
|
|
||||||
)
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert rows[0].was_on_time is False
|
|
||||||
|
|
||||||
_with_db(tmp_path, "late-after-due-time.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_microsecond_after_due_time_is_late(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
instance = await QuestInstancesDao(database).upsert(
|
|
||||||
instance.definition_id,
|
|
||||||
child.id,
|
|
||||||
instance.due_date,
|
|
||||||
_now_stamp(),
|
|
||||||
window=instance.window,
|
|
||||||
due_time="12:00",
|
|
||||||
)
|
|
||||||
now = datetime.datetime(
|
|
||||||
due_date.year, due_date.month, due_date.day, 12, 0, 0, 500,
|
|
||||||
tzinfo=_HA_TZ,
|
|
||||||
)
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=now,
|
|
||||||
today=due_date,
|
|
||||||
)
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert rows[0].was_on_time is False
|
|
||||||
|
|
||||||
_with_db(tmp_path, "late-microsecond-after-due-time.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_no_due_time_on_due_date_is_on_time(
|
|
||||||
tmp_path,
|
|
||||||
) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
assert instance.due_time is None
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=_ha_local(due_date, 23, 59),
|
|
||||||
today=due_date,
|
|
||||||
)
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert rows[0].was_on_time is True
|
|
||||||
|
|
||||||
_with_db(tmp_path, "ontime-no-due-time.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_no_due_time_after_due_date_is_late(
|
|
||||||
tmp_path,
|
|
||||||
) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
later = due_date + datetime.timedelta(days=1)
|
|
||||||
assert instance.due_time is None
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=_ha_local(later, 8, 0),
|
|
||||||
today=later,
|
|
||||||
)
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert rows[0].was_on_time is False
|
|
||||||
|
|
||||||
_with_db(tmp_path, "late-after-due-date.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_ha_local_wall_clock_not_utc(tmp_path) -> None:
|
|
||||||
"""HA-local 11:00 with due_time 12:00 is on time even when UTC is 19:00.
|
|
||||||
|
|
||||||
The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting
|
|
||||||
to UTC before comparing would mark this late.
|
|
||||||
"""
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
instance = await QuestInstancesDao(database).upsert(
|
|
||||||
instance.definition_id,
|
|
||||||
child.id,
|
|
||||||
instance.due_date,
|
|
||||||
_now_stamp(),
|
|
||||||
window=instance.window,
|
|
||||||
due_time="12:00",
|
|
||||||
)
|
|
||||||
now = _ha_local(due_date, 11, 0)
|
|
||||||
assert now.astimezone(datetime.timezone.utc).hour == 19
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=now,
|
|
||||||
today=due_date,
|
|
||||||
)
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert rows[0].was_on_time is True
|
|
||||||
|
|
||||||
_with_db(tmp_path, "ontime-ha-local-not-utc.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_due_time_requires_now_on_due_date(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
instance = await QuestInstancesDao(database).upsert(
|
|
||||||
instance.definition_id,
|
|
||||||
child.id,
|
|
||||||
instance.due_date,
|
|
||||||
_now_stamp(),
|
|
||||||
window=instance.window,
|
|
||||||
due_time="12:00",
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="now is required to compare due_time"):
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
today=due_date,
|
|
||||||
)
|
|
||||||
|
|
||||||
_with_db(tmp_path, "due-time-requires-now.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# uncomplete_instance: append uncompleted, no-op if already open
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_appends_and_leaves_original_untouched(
|
|
||||||
tmp_path,
|
|
||||||
) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
completed_at = datetime.datetime(
|
|
||||||
2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc
|
|
||||||
)
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=completed_at,
|
|
||||||
)
|
|
||||||
events = CompletionEventsDao(database)
|
|
||||||
before = await events.list_by_instance(instance.id)
|
|
||||||
assert len(before) == 1
|
|
||||||
original = before[0]
|
|
||||||
assert original.event_type == EVENT_COMPLETED
|
|
||||||
assert original.occurred_at == "2026-09-18T08:00:00+00:00"
|
|
||||||
assert original.actor_source == "user"
|
|
||||||
assert original.actor_user_id == "user-1"
|
|
||||||
assert original.actor_child_id is None
|
|
||||||
uncompleted_at = completed_at + datetime.timedelta(seconds=1)
|
|
||||||
state = await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="panel",
|
|
||||||
actor_child_id=child.id,
|
|
||||||
now=uncompleted_at,
|
|
||||||
)
|
|
||||||
assert state == "open"
|
|
||||||
assert await instance_state(database, instance.id) == "open"
|
|
||||||
after = await events.list_by_instance(instance.id)
|
|
||||||
assert len(after) == 2
|
|
||||||
assert after[0].id == original.id
|
|
||||||
assert after[0].event_type == EVENT_COMPLETED
|
|
||||||
assert after[0].occurred_at == original.occurred_at
|
|
||||||
assert after[0].actor_source == original.actor_source
|
|
||||||
assert after[0].actor_user_id == original.actor_user_id
|
|
||||||
assert after[0].actor_child_id == original.actor_child_id
|
|
||||||
assert after[1].event_type == EVENT_UNCOMPLETED
|
|
||||||
assert after[1].actor_source == "panel"
|
|
||||||
assert after[1].actor_child_id == child.id
|
|
||||||
assert after[1].was_on_time is None
|
|
||||||
|
|
||||||
_with_db(tmp_path, "uncomplete-append.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_already_open_is_noop(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
events = CompletionEventsDao(database)
|
|
||||||
assert await events.list_by_instance(instance.id) == []
|
|
||||||
first = await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
after_first = await events.list_by_instance(instance.id)
|
|
||||||
assert first == "open"
|
|
||||||
assert after_first == []
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
before = await events.list_by_instance(instance.id)
|
|
||||||
assert len(before) == 2
|
|
||||||
second = await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-2",
|
|
||||||
)
|
|
||||||
after = await events.list_by_instance(instance.id)
|
|
||||||
assert second == "open"
|
|
||||||
assert after == before
|
|
||||||
assert after[0].actor_user_id == "user-1"
|
|
||||||
assert after[1].actor_user_id == "user-1"
|
|
||||||
|
|
||||||
_with_db(tmp_path, "uncomplete-noop.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_unknown_instance_id_raises(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
with pytest.raises(ValueError, match="instance_id"):
|
|
||||||
await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id + 999,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
_with_db(tmp_path, "uncomplete-unknown.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_rejects_non_integer_id() -> None:
|
|
||||||
async def _body():
|
|
||||||
with pytest.raises(ValueError, match="instance_id"):
|
|
||||||
await uncomplete_instance(
|
|
||||||
object(),
|
|
||||||
"1",
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="instance_id"):
|
|
||||||
await uncomplete_instance(
|
|
||||||
object(),
|
|
||||||
True,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
_run(_body())
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_now_pins_occurred_at(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=datetime.datetime(
|
|
||||||
2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc
|
|
||||||
),
|
|
||||||
)
|
|
||||||
pinned = datetime.datetime(
|
|
||||||
2026, 9, 18, 8, 0, 1, tzinfo=datetime.timezone.utc
|
|
||||||
)
|
|
||||||
await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=pinned,
|
|
||||||
)
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert rows[1].occurred_at == "2026-09-18T08:00:01+00:00"
|
|
||||||
|
|
||||||
_with_db(tmp_path, "uncomplete-pinned.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
|
||||||
"""Two racing uncompletes cannot both append.
|
|
||||||
|
|
||||||
Deterministic gate: task A is paused at its INSERT (it holds the
|
|
||||||
connection lock); task B is then started and must queue. A
|
|
||||||
finishes, B sees open and appends nothing.
|
|
||||||
"""
|
|
||||||
async def _main():
|
|
||||||
armed = {"active": False, "gated": False}
|
|
||||||
gate_open = asyncio.Event()
|
|
||||||
a_started = asyncio.Event()
|
|
||||||
hass = _gated_hass(armed, gate_open, a_started)
|
|
||||||
database = NestQuestDatabase(hass)
|
|
||||||
await database.open(tmp_path / "uncomplete-race.db")
|
|
||||||
try:
|
|
||||||
await apply_migrations(database)
|
|
||||||
children = ChildrenDao(database)
|
|
||||||
rules = ScheduleRulesDao(database)
|
|
||||||
definitions = QuestDefinitionsDao(database)
|
|
||||||
instances = QuestInstancesDao(database)
|
|
||||||
child = await children.create("Ada", _now_stamp())
|
|
||||||
rule = await rules.create("daily", D1)
|
|
||||||
definition = await definitions.create(
|
|
||||||
"Brush teeth",
|
|
||||||
rule.id,
|
|
||||||
_now_stamp(),
|
|
||||||
assignee_child_ids=[child.id],
|
|
||||||
)
|
|
||||||
instance = await instances.upsert(
|
|
||||||
definition.id,
|
|
||||||
child.id,
|
|
||||||
D1,
|
|
||||||
_now_stamp(),
|
|
||||||
window="morning",
|
|
||||||
)
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
armed["active"] = True
|
|
||||||
task_a = asyncio.ensure_future(
|
|
||||||
uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await a_started.wait()
|
|
||||||
armed["active"] = False
|
|
||||||
task_b = asyncio.ensure_future(
|
|
||||||
uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-2",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
gate_open.set()
|
|
||||||
state_a, state_b = await asyncio.gather(task_a, task_b)
|
|
||||||
assert state_a == "open"
|
|
||||||
assert state_b == "open"
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(
|
|
||||||
instance.id
|
|
||||||
)
|
|
||||||
assert len(rows) == 2
|
|
||||||
assert rows[0].event_type == EVENT_COMPLETED
|
|
||||||
assert rows[1].event_type == EVENT_UNCOMPLETED
|
|
||||||
assert rows[1].actor_user_id == "user-1"
|
|
||||||
finally:
|
|
||||||
await database.close()
|
|
||||||
|
|
||||||
_run(_main())
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# missed: derived for past-due open instances, never stored
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_append_event_rejects_missed_event_type() -> None:
|
|
||||||
async def _body():
|
|
||||||
with pytest.raises(ValueError, match="event_type"):
|
|
||||||
await append_event(
|
|
||||||
object(),
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
"missed",
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
was_on_time=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
_run(_body())
|
|
||||||
|
|
||||||
|
|
||||||
def test_instance_state_missed_when_past_due_and_open(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
later = due + datetime.timedelta(days=1)
|
|
||||||
assert await instance_state(database, instance.id, today=later) == "missed"
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(instance.id)
|
|
||||||
assert rows == []
|
|
||||||
|
|
||||||
_with_db(tmp_path, "state-missed-open.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_instance_state_open_when_due_today(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
assert await instance_state(database, instance.id, today=due) == "open"
|
|
||||||
|
|
||||||
_with_db(tmp_path, "state-open-due-today.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_instance_state_done_when_past_due_and_completed(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
today=due,
|
|
||||||
)
|
|
||||||
later = due + datetime.timedelta(days=1)
|
|
||||||
assert await instance_state(database, instance.id, today=later) == "done"
|
|
||||||
|
|
||||||
_with_db(tmp_path, "state-done-past-due.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_instance_state_missed_after_uncompleted_when_past_due(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
await append_event(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
child.id,
|
|
||||||
EVENT_COMPLETED,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
was_on_time=True,
|
|
||||||
)
|
|
||||||
await append_event(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
child.id,
|
|
||||||
EVENT_UNCOMPLETED,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
)
|
|
||||||
later = due + datetime.timedelta(days=1)
|
|
||||||
assert await instance_state(database, instance.id, today=later) == "missed"
|
|
||||||
|
|
||||||
_with_db(tmp_path, "state-missed-uncompleted.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_instance_state_missed_uses_host_today_when_unthreaded(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
yesterday = datetime.date.today() - datetime.timedelta(days=1)
|
|
||||||
past = await QuestInstancesDao(database).upsert(
|
|
||||||
instance.definition_id,
|
|
||||||
child.id,
|
|
||||||
yesterday.isoformat(),
|
|
||||||
_now_stamp(),
|
|
||||||
window="afternoon",
|
|
||||||
today=yesterday,
|
|
||||||
)
|
|
||||||
assert await instance_state(database, past.id) == "missed"
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(past.id)
|
|
||||||
assert rows == []
|
|
||||||
assert all(row.event_type != "missed" for row in rows)
|
|
||||||
|
|
||||||
_with_db(tmp_path, "state-missed-host-today.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_past_due_done_returns_missed_not_done(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
today=due,
|
|
||||||
)
|
|
||||||
later = due + datetime.timedelta(days=1)
|
|
||||||
assert await instance_state(database, instance.id, today=later) == "done"
|
|
||||||
state = await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
today=later,
|
|
||||||
)
|
|
||||||
assert state == "missed"
|
|
||||||
assert await instance_state(database, instance.id, today=later) == "missed"
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(instance.id)
|
|
||||||
assert [row.event_type for row in rows] == [
|
|
||||||
EVENT_COMPLETED,
|
|
||||||
EVENT_UNCOMPLETED,
|
|
||||||
]
|
|
||||||
assert all(row.event_type != "missed" for row in rows)
|
|
||||||
|
|
||||||
_with_db(tmp_path, "uncomplete-past-due-missed.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_uncomplete_past_due_already_open_is_noop_missed(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
later = datetime.date.fromisoformat(instance.due_date) + datetime.timedelta(
|
|
||||||
days=1
|
|
||||||
)
|
|
||||||
events = CompletionEventsDao(database)
|
|
||||||
assert await events.list_by_instance(instance.id) == []
|
|
||||||
state = await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
today=later,
|
|
||||||
)
|
|
||||||
assert state == "missed"
|
|
||||||
assert await events.list_by_instance(instance.id) == []
|
|
||||||
|
|
||||||
_with_db(tmp_path, "uncomplete-past-due-noop.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_instance_of_missed_returns_done(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
later = due + datetime.timedelta(days=1)
|
|
||||||
assert await instance_state(database, instance.id, today=later) == "missed"
|
|
||||||
state = await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=_ha_local(later, 8, 0),
|
|
||||||
today=later,
|
|
||||||
)
|
|
||||||
assert state == "done"
|
|
||||||
assert await instance_state(database, instance.id, today=later) == "done"
|
|
||||||
rows = await CompletionEventsDao(database).list_by_instance(instance.id)
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0].event_type == EVENT_COMPLETED
|
|
||||||
assert rows[0].was_on_time is False
|
|
||||||
|
|
||||||
_with_db(tmp_path, "complete-missed.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_missed_for_child_over_date_range(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
later_due = due + datetime.timedelta(days=1)
|
|
||||||
other = await QuestInstancesDao(database).upsert(
|
|
||||||
instance.definition_id,
|
|
||||||
child.id,
|
|
||||||
later_due.isoformat(),
|
|
||||||
_now_stamp(),
|
|
||||||
window="morning",
|
|
||||||
)
|
|
||||||
await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
today=due,
|
|
||||||
)
|
|
||||||
today = later_due + datetime.timedelta(days=1)
|
|
||||||
found = await list_missed_for_child(
|
|
||||||
database,
|
|
||||||
child.id,
|
|
||||||
instance.due_date,
|
|
||||||
later_due.isoformat(),
|
|
||||||
today=today,
|
|
||||||
)
|
|
||||||
assert [row.id for row in found] == [other.id]
|
|
||||||
events = CompletionEventsDao(database)
|
|
||||||
assert await events.list_by_instance(other.id) == []
|
|
||||||
done_rows = await events.list_by_instance(instance.id)
|
|
||||||
assert [row.event_type for row in done_rows] == [EVENT_COMPLETED]
|
|
||||||
assert all(row.event_type != "missed" for row in done_rows)
|
|
||||||
|
|
||||||
_with_db(tmp_path, "list-missed-range.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_missed_for_child_empty_when_due_today(tmp_path) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
found = await list_missed_for_child(
|
|
||||||
database,
|
|
||||||
child.id,
|
|
||||||
instance.due_date,
|
|
||||||
instance.due_date,
|
|
||||||
today=due,
|
|
||||||
)
|
|
||||||
assert found == []
|
|
||||||
|
|
||||||
_with_db(tmp_path, "list-missed-due-today.db")(_body)
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_missed_for_child_rejects_non_integer_id() -> None:
|
|
||||||
async def _body():
|
|
||||||
with pytest.raises(ValueError, match="child_id"):
|
|
||||||
await list_missed_for_child(object(), "1", D1, D1)
|
|
||||||
with pytest.raises(ValueError, match="child_id"):
|
|
||||||
await list_missed_for_child(object(), True, D1, D1)
|
|
||||||
|
|
||||||
_run(_body())
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# lifecycle: complete → uncomplete → complete
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_complete_uncomplete_complete_three_events_final_done(
|
|
||||||
tmp_path,
|
|
||||||
) -> None:
|
|
||||||
async def _body(database, child, instance):
|
|
||||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
|
||||||
later = due_date + datetime.timedelta(days=1)
|
|
||||||
instance = await QuestInstancesDao(database).upsert(
|
|
||||||
instance.definition_id,
|
|
||||||
child.id,
|
|
||||||
instance.due_date,
|
|
||||||
_now_stamp(),
|
|
||||||
window=instance.window,
|
|
||||||
due_time="12:00",
|
|
||||||
)
|
|
||||||
events = CompletionEventsDao(database)
|
|
||||||
|
|
||||||
first = await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="panel",
|
|
||||||
actor_child_id=child.id,
|
|
||||||
now=_ha_local(due_date, 11, 0),
|
|
||||||
today=due_date,
|
|
||||||
)
|
|
||||||
assert first == "done"
|
|
||||||
before_uncomplete = await events.list_by_instance(instance.id)
|
|
||||||
assert len(before_uncomplete) == 1
|
|
||||||
original = before_uncomplete[0]
|
|
||||||
assert original.event_type == EVENT_COMPLETED
|
|
||||||
assert original.was_on_time is True
|
|
||||||
assert original.actor_source == "panel"
|
|
||||||
assert original.actor_user_id is None
|
|
||||||
assert original.actor_child_id == child.id
|
|
||||||
assert await instance_state(database, instance.id, today=later) == "done"
|
|
||||||
|
|
||||||
reversed_state = await uncomplete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="user",
|
|
||||||
actor_user_id="user-1",
|
|
||||||
now=_ha_local(later, 8, 0),
|
|
||||||
today=later,
|
|
||||||
)
|
|
||||||
assert reversed_state == "missed"
|
|
||||||
assert await instance_state(database, instance.id, today=later) == "missed"
|
|
||||||
|
|
||||||
second = await complete_instance(
|
|
||||||
database,
|
|
||||||
instance.id,
|
|
||||||
actor_source="panel",
|
|
||||||
actor_child_id=child.id,
|
|
||||||
now=_ha_local(later, 9, 0),
|
|
||||||
today=later,
|
|
||||||
)
|
|
||||||
assert second == "done"
|
|
||||||
assert await instance_state(database, instance.id, today=later) == "done"
|
|
||||||
|
|
||||||
after = await events.list_by_instance(instance.id)
|
|
||||||
assert [row.event_type for row in after] == [
|
|
||||||
EVENT_COMPLETED,
|
|
||||||
EVENT_UNCOMPLETED,
|
|
||||||
EVENT_COMPLETED,
|
|
||||||
]
|
|
||||||
assert after[0].id == original.id
|
|
||||||
assert after[0].occurred_at == original.occurred_at
|
|
||||||
assert after[0].actor_source == original.actor_source
|
|
||||||
assert after[0].actor_user_id == original.actor_user_id
|
|
||||||
assert after[0].actor_child_id == original.actor_child_id
|
|
||||||
assert after[0].was_on_time is True
|
|
||||||
assert after[1].actor_source == "user"
|
|
||||||
assert after[1].actor_user_id == "user-1"
|
|
||||||
assert after[1].actor_child_id is None
|
|
||||||
assert after[1].was_on_time is None
|
|
||||||
assert after[2].actor_source == "panel"
|
|
||||||
assert after[2].actor_user_id is None
|
|
||||||
assert after[2].actor_child_id == child.id
|
|
||||||
assert after[2].was_on_time is False
|
|
||||||
assert all(row.event_type != "missed" for row in after)
|
|
||||||
|
|
||||||
_with_db(tmp_path, "lifecycle-three.db")(_body)
|
|
||||||
|
|||||||
@@ -455,20 +455,6 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
|
|||||||
assert _with_db(tmp_path, "override-list-unknown.db")(_body) == []
|
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(
|
||||||
|
|||||||
+8
-21
@@ -9,7 +9,6 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
|||||||
|
|
||||||
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
@@ -17,13 +16,6 @@ from custom_components.nestquest.const import (
|
|||||||
DOMAIN as DOMAIN_CONST,
|
DOMAIN as DOMAIN_CONST,
|
||||||
)
|
)
|
||||||
|
|
||||||
ADMIN_ID = "admin-1"
|
|
||||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_data():
|
|
||||||
return {CONF_ADMIN_USER_IDS: [ADMIN_ID]}
|
|
||||||
|
|
||||||
|
|
||||||
def _wire(entry, registry):
|
def _wire(entry, registry):
|
||||||
return wire_entry_to_registry(entry, registry)
|
return wire_entry_to_registry(entry, registry)
|
||||||
@@ -554,7 +546,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(data=_admin_data()), hass.registry)
|
entry = _wire(make_entry(), 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)
|
||||||
|
|
||||||
@@ -563,7 +555,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, context=ADMIN_CTX)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
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()
|
||||||
@@ -582,15 +574,14 @@ async def test_regenerate_service_uses_configured_horizon_days(
|
|||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
entry = _wire(
|
entry = _wire(
|
||||||
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
|
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
|
||||||
hass.registry,
|
|
||||||
)
|
)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
|
||||||
database = entry.runtime_data.database
|
database = entry.runtime_data.database
|
||||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||||
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
today = _local_today(hass)
|
today = _local_today(hass)
|
||||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||||
@@ -631,12 +622,8 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
|
|||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
entry_a = _wire(
|
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
|
||||||
make_entry(entry_id="entry_a", data=_admin_data()), hass.registry
|
entry_b = _wire(make_entry(entry_id="entry_b"), 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
|
||||||
@@ -647,7 +634,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, context=ADMIN_CTX)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
||||||
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(
|
||||||
@@ -660,7 +647,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, context=ADMIN_CTX)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Tests for the Feature 09 admin-only versus open service registry."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from custom_components.nestquest.const import (
|
|
||||||
DOMAIN_SERVICES,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
)
|
|
||||||
from custom_components.nestquest.service_policy import (
|
|
||||||
ADMIN_ONLY,
|
|
||||||
OPEN,
|
|
||||||
SERVICE_POLICY,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_policy_covers_exactly_the_registered_services() -> None:
|
|
||||||
assert frozenset(SERVICE_POLICY) == frozenset(DOMAIN_SERVICES)
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_complete_quest_is_open() -> None:
|
|
||||||
open_services = {
|
|
||||||
name for name, access in SERVICE_POLICY.items() if access == OPEN
|
|
||||||
}
|
|
||||||
assert open_services == {SERVICE_COMPLETE_QUEST}
|
|
||||||
assert all(
|
|
||||||
access == ADMIN_ONLY
|
|
||||||
for name, access in SERVICE_POLICY.items()
|
|
||||||
if name != SERVICE_COMPLETE_QUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_policy_values_are_only_open_or_admin_only() -> None:
|
|
||||||
assert set(SERVICE_POLICY.values()) <= {OPEN, ADMIN_ONLY}
|
|
||||||
+7
-559
@@ -1,33 +1,9 @@
|
|||||||
"""Tests for NestQuest service declarations and domain-global handlers."""
|
"""Tests for the NestQuest services.yaml declaration file."""
|
||||||
from __future__ import annotations
|
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"
|
||||||
@@ -36,47 +12,10 @@ SERVICES_PATH = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _wire(entry, registry):
|
|
||||||
return wire_entry_to_registry(entry, registry)
|
|
||||||
|
|
||||||
|
|
||||||
ADMIN_ID = "admin-1"
|
|
||||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
|
||||||
|
|
||||||
|
|
||||||
def _make_admin_entry(make_entry):
|
|
||||||
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
|
||||||
|
|
||||||
return make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]})
|
|
||||||
|
|
||||||
|
|
||||||
def _service_section(text: str, name: str) -> str:
|
|
||||||
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
|
|
||||||
assert match is not None, f"services.yaml must declare {name!r}"
|
|
||||||
section = text[match.end() :]
|
|
||||||
next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE)
|
|
||||||
if next_service is not None:
|
|
||||||
section = section[: next_service.start()]
|
|
||||||
return section
|
|
||||||
|
|
||||||
|
|
||||||
def test_services_yaml_exists() -> None:
|
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), (
|
||||||
@@ -88,506 +27,15 @@ def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
|
|||||||
"""The regenerate service is documented with a clear description and no
|
"""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")
|
||||||
section = _service_section(text, "regenerate")
|
match = re.search(r"^regenerate:\s*$", text, re.MULTILINE)
|
||||||
|
assert match is not None
|
||||||
|
section = text[match.end():]
|
||||||
|
next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE)
|
||||||
|
if next_service is not None:
|
||||||
|
section = section[: next_service.start()]
|
||||||
assert re.search(r"description:", section), (
|
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