Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f49b44b5 | ||
|
|
2a0e568940 | ||
|
|
37d7b7e6c3 | ||
|
|
dd7ea10936 | ||
|
|
4281b9d01c | ||
|
|
a2e9bdffee | ||
|
|
b1c42f10d0 | ||
|
|
2d30f0ce77 | ||
|
|
c4fb2fbac5 | ||
|
|
0820544fce |
@@ -24,11 +24,11 @@ from .const import (
|
|||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
SERVICE_REGENERATE,
|
|
||||||
)
|
)
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
from .materialize import materialize as _materialize_run
|
from .materialize import materialize as _materialize_run
|
||||||
from .migrations import apply_migrations
|
from .migrations import apply_migrations
|
||||||
|
from .services import async_deregister_services, async_register_services
|
||||||
from .store import async_get_db_path
|
from .store import async_get_db_path
|
||||||
from .admin_allowlist import seed_setup_admin
|
from .admin_allowlist import seed_setup_admin
|
||||||
|
|
||||||
@@ -356,21 +356,8 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _register_regenerate_service(hass: HomeAssistant) -> None:
|
def _make_regenerate_handler(hass: HomeAssistant):
|
||||||
"""Register ``nestquest.regenerate`` once at domain scope.
|
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
||||||
|
|
||||||
The service triggers a full on-demand regeneration over the rolling
|
|
||||||
horizon, running the SAME idempotent materialize path
|
|
||||||
(:func:`_run_horizon_materialization`) as the startup backfill and the
|
|
||||||
daily rollover listener. It is DOMAIN-global — registered exactly once
|
|
||||||
(guarded by ``has_service``) and NOT bound to any config entry — so the
|
|
||||||
handler resolves the currently-live database at call time and a name
|
|
||||||
collision or duplicate setup is impossible. Removal is handled by the
|
|
||||||
unload path only once the LAST entry is removed (see
|
|
||||||
:func:`_deregister_regenerate_service`).
|
|
||||||
"""
|
|
||||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
|
||||||
return
|
|
||||||
|
|
||||||
async def _regenerate(_call: Any) -> None:
|
async def _regenerate(_call: Any) -> None:
|
||||||
runtime_data = _find_live_runtime_data(hass)
|
runtime_data = _find_live_runtime_data(hass)
|
||||||
@@ -385,13 +372,29 @@ def _register_regenerate_service(hass: HomeAssistant) -> None:
|
|||||||
hass, runtime_data.database, horizon_days
|
hass, runtime_data.database, horizon_days
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
|
return _regenerate
|
||||||
|
|
||||||
|
|
||||||
def _deregister_regenerate_service(hass: HomeAssistant) -> None:
|
def _register_services(hass: HomeAssistant) -> None:
|
||||||
"""Remove ``nestquest.regenerate`` when the last entry unloads."""
|
"""Register domain-global NestQuest services once.
|
||||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
|
||||||
hass.services.async_remove(DOMAIN, SERVICE_REGENERATE)
|
Services are DOMAIN-global — registered exactly once (guarded by
|
||||||
|
``has_service`` on regenerate) and NOT bound to any config entry —
|
||||||
|
so each handler resolves the currently-live database at call time
|
||||||
|
and a name collision or duplicate setup is impossible. Removal is
|
||||||
|
handled by the unload path only once the LAST entry is removed
|
||||||
|
(see :func:`_deregister_services`).
|
||||||
|
"""
|
||||||
|
async_register_services(
|
||||||
|
hass,
|
||||||
|
find_runtime=_find_live_runtime_data,
|
||||||
|
regenerate_handler=_make_regenerate_handler(hass),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _deregister_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Remove domain-global NestQuest services when the last entry unloads."""
|
||||||
|
async_deregister_services(hass)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
@@ -489,7 +492,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
await _run_horizon_materialization(
|
await _run_horizon_materialization(
|
||||||
hass, database, _configured_horizon_days(dict(entry.options))
|
hass, database, _configured_horizon_days(dict(entry.options))
|
||||||
)
|
)
|
||||||
_register_regenerate_service(hass)
|
_register_services(hass)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# A failure anywhere after the database is open must not leak the
|
# A failure anywhere after the database is open must not leak the
|
||||||
# listeners already registered against that (now-closed) database:
|
# listeners already registered against that (now-closed) database:
|
||||||
@@ -562,11 +565,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
database = getattr(runtime_data, "database", None)
|
database = getattr(runtime_data, "database", None)
|
||||||
if database is not None:
|
if database is not None:
|
||||||
await database.close()
|
await database.close()
|
||||||
# The ``regenerate`` service is DOMAIN-global: it is torn down only
|
# Domain-global services are torn down only once the FINAL entry
|
||||||
# once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty),
|
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
||||||
# never when a sibling entry is still loaded.
|
# entry is still loaded.
|
||||||
if is_last:
|
if is_last:
|
||||||
_deregister_regenerate_service(hass)
|
_deregister_services(hass)
|
||||||
if getattr(entry, "runtime_data", None) is not None:
|
if getattr(entry, "runtime_data", None) is not None:
|
||||||
entry.runtime_data = None
|
entry.runtime_data = None
|
||||||
if unload_error is not None:
|
if unload_error is not None:
|
||||||
|
|||||||
@@ -9,9 +9,34 @@ LOGGER = logging.getLogger(LOGGER_NAME)
|
|||||||
|
|
||||||
PLATFORMS: list[str] = []
|
PLATFORMS: list[str] = []
|
||||||
|
|
||||||
#: Service name for the on-demand regeneration service (``nestquest.regenerate``).
|
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||||
|
#: plus the existing Feature 07 regenerate service).
|
||||||
|
SERVICE_COMPLETE_QUEST = "complete_quest"
|
||||||
|
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
|
||||||
|
SERVICE_MANAGE_CHILD = "manage_child"
|
||||||
SERVICE_REGENERATE = "regenerate"
|
SERVICE_REGENERATE = "regenerate"
|
||||||
|
|
||||||
|
DOMAIN_SERVICES: tuple[str, ...] = (
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN,
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
SERVICE_REGENERATE,
|
||||||
|
)
|
||||||
|
|
||||||
SQLITE_DB_FILENAME = "nestquest.db"
|
SQLITE_DB_FILENAME = "nestquest.db"
|
||||||
|
|
||||||
# Configuration keys and default values
|
# Configuration keys and default values
|
||||||
|
|||||||
@@ -270,6 +270,15 @@ class PresenceOverridesDao:
|
|||||||
)
|
)
|
||||||
return [_override_from_row(row) for row in rows]
|
return [_override_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
|
||||||
|
"""Return one override by id, or None when it does not exist."""
|
||||||
|
row = await self._database.fetch_one(
|
||||||
|
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(override_id,),
|
||||||
|
)
|
||||||
|
return _override_from_row(row) if row is not None else None
|
||||||
|
|
||||||
async def delete(self, override_id: int) -> bool:
|
async def delete(self, override_id: int) -> bool:
|
||||||
"""Remove one override by id; True if a row was removed."""
|
"""Remove one override by id; True if a row was removed."""
|
||||||
result = await self._database.execute(
|
result = await self._database.execute(
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Permission gate for NestQuest services (Feature 09).
|
||||||
|
|
||||||
|
A wrapper factory, applied to EVERY registered service at registration
|
||||||
|
time (no handler is exempt), reads ``call.context.user_id``, consults
|
||||||
|
the :data:`~.service_policy.SERVICE_POLICY` registry and
|
||||||
|
:func:`~.admin_allowlist.is_admin`, and raises
|
||||||
|
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
|
||||||
|
operation called by a non-admin — including a call with NO user
|
||||||
|
context, which fails closed. ``complete_quest`` (policy ``open``)
|
||||||
|
proceeds regardless of the allowlist.
|
||||||
|
|
||||||
|
Logging (later Feature 09 tasks): every denied admin-only call logs a
|
||||||
|
warning and every successful one logs a debug line, both carrying the
|
||||||
|
service, the user id (or ``none``), and a UTC timestamp.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.exceptions import Unauthorized
|
||||||
|
|
||||||
|
from .admin_allowlist import is_admin
|
||||||
|
from .service_policy import ADMIN_ONLY
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
def _context_user_id(call: Any) -> str | None:
|
||||||
|
context = getattr(call, "context", None)
|
||||||
|
user_id = getattr(context, "user_id", None) if context is not None else None
|
||||||
|
if isinstance(user_id, str) and user_id:
|
||||||
|
return user_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp() -> str:
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def permission_gate(
|
||||||
|
service: str,
|
||||||
|
policy: str,
|
||||||
|
database_of: Callable[[Any], Any],
|
||||||
|
) -> Callable[[ServiceHandler], ServiceHandler]:
|
||||||
|
"""Wrap ``handler`` with the Feature 09 permission check.
|
||||||
|
|
||||||
|
``database_of`` resolves the live database from the call-time
|
||||||
|
runtime (the same ``find_runtime`` the handler uses), so the gate
|
||||||
|
never opens its own connection. The verdict is fail-closed:
|
||||||
|
absent allowlist row, unknown id, no user context — all not admin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _wrap(handler: ServiceHandler) -> ServiceHandler:
|
||||||
|
if policy != ADMIN_ONLY:
|
||||||
|
return handler
|
||||||
|
|
||||||
|
async def _gated(call: Any) -> None:
|
||||||
|
user_id = _context_user_id(call)
|
||||||
|
allowed = user_id is not None and await is_admin(
|
||||||
|
database_of(call), user_id
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
LOGGER.warning(
|
||||||
|
"NestQuest denied %s: caller user_id=%s at %s",
|
||||||
|
service,
|
||||||
|
user_id if user_id is not None else "none",
|
||||||
|
_stamp(),
|
||||||
|
)
|
||||||
|
raise Unauthorized(
|
||||||
|
f"User '{user_id if user_id is not None else 'none'}' "
|
||||||
|
f"is not allowed to call nestquest.{service}"
|
||||||
|
)
|
||||||
|
LOGGER.debug(
|
||||||
|
"NestQuest allowed %s: caller user_id=%s at %s",
|
||||||
|
service,
|
||||||
|
user_id,
|
||||||
|
_stamp(),
|
||||||
|
)
|
||||||
|
await handler(call)
|
||||||
|
|
||||||
|
return _gated
|
||||||
|
|
||||||
|
return _wrap
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Explicit admin-only versus open service registry (Feature 09).
|
||||||
|
|
||||||
|
One mapping, one file: every registered ``nestquest`` service name is
|
||||||
|
either ``open`` or ``admin_only``. The permission decorator (later
|
||||||
|
Feature 09 tasks) reads this table; it does not take per-handler
|
||||||
|
policy arguments.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DOMAIN_SERVICES,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
SERVICE_REGENERATE,
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPEN = "open"
|
||||||
|
ADMIN_ONLY = "admin_only"
|
||||||
|
|
||||||
|
SERVICE_POLICY: dict[str, str] = {
|
||||||
|
SERVICE_COMPLETE_QUEST: OPEN,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
|
||||||
|
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
|
||||||
|
SERVICE_REGENERATE: ADMIN_ONLY,
|
||||||
|
}
|
||||||
|
|
||||||
|
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
|
||||||
|
raise RuntimeError(
|
||||||
|
"SERVICE_POLICY must name exactly the registered nestquest services, "
|
||||||
|
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
"""Domain-global NestQuest service schemas and handlers (Feature 09).
|
||||||
|
|
||||||
|
Registration is domain-scoped and happens once (see
|
||||||
|
:func:`async_register_services`); handlers resolve a live database at
|
||||||
|
call time through the injected ``find_runtime`` callable. Permission
|
||||||
|
checks are Feature 09 later tasks — this module only validates payload
|
||||||
|
shape and forwards to the Feature 03/06/08 business layers (and the
|
||||||
|
presence DAOs). ``export_history_csv`` is registered with a schema but
|
||||||
|
raises until Feature 13. ``regenerate`` is supplied by the caller so
|
||||||
|
the Feature 07 handler stays the one in ``__init__``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
|
from . import children as children_layer
|
||||||
|
from . import completion as completion_layer
|
||||||
|
from . import quest_definitions as quest_definitions_layer
|
||||||
|
from .const import (
|
||||||
|
CONF_HORIZON_DAYS,
|
||||||
|
DEFAULT_HORIZON_DAYS,
|
||||||
|
DOMAIN,
|
||||||
|
DOMAIN_SERVICES,
|
||||||
|
QUEST_WINDOWS,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
SERVICE_REGENERATE,
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||||
|
)
|
||||||
|
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||||
|
from .materialize import regenerate_for_child
|
||||||
|
from .permissions import permission_gate
|
||||||
|
from .presence import PresenceSchedule
|
||||||
|
from .recurrence import ScheduleRule
|
||||||
|
from .service_policy import SERVICE_POLICY
|
||||||
|
|
||||||
|
FindRuntime = Callable[[HomeAssistant], Any]
|
||||||
|
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||||
|
|
||||||
|
ACTOR_PANEL = "panel"
|
||||||
|
ACTOR_USER = "user"
|
||||||
|
MANAGE_CHILD_CREATE = "create"
|
||||||
|
MANAGE_CHILD_EDIT = "edit"
|
||||||
|
MANAGE_CHILD_SET_ACTIVE = "set_active"
|
||||||
|
MANAGE_CHILD_REORDER = "reorder"
|
||||||
|
|
||||||
|
|
||||||
|
def _strict_int(value: object) -> int:
|
||||||
|
"""Reject bools and non-ints; voluptuous ``int`` would accept True."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise vol.Invalid(f"expected int, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _strict_bool(value: object) -> bool:
|
||||||
|
"""Reject ints and other truthy values; require a real bool."""
|
||||||
|
if type(value) is not bool:
|
||||||
|
raise vol.Invalid(f"expected bool, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _weekday_list(value: object) -> list[int]:
|
||||||
|
if type(value) is not list:
|
||||||
|
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
|
||||||
|
return [_strict_int(item) for item in value]
|
||||||
|
|
||||||
|
|
||||||
|
def _presence_pattern(value: object) -> dict[int, list[int]]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
|
||||||
|
pattern: dict[int, list[int]] = {}
|
||||||
|
for week, days in value.items():
|
||||||
|
if isinstance(week, str):
|
||||||
|
try:
|
||||||
|
week_index = int(week)
|
||||||
|
except ValueError as err:
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"pattern week index must be an integer, got {week!r}"
|
||||||
|
) from err
|
||||||
|
if str(week_index) != week:
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"pattern week index must be an integer, got {week!r}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
week_index = _strict_int(week)
|
||||||
|
pattern[week_index] = _weekday_list(days)
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
|
||||||
|
def _window_entry(value: object) -> str | tuple[str, str | None]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value not in QUEST_WINDOWS:
|
||||||
|
raise vol.Invalid(f"unknown window {value!r}")
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
parsed = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("window"): vol.In(QUEST_WINDOWS),
|
||||||
|
vol.Optional("due_time"): str,
|
||||||
|
}
|
||||||
|
)(value)
|
||||||
|
due_time = parsed.get("due_time")
|
||||||
|
if due_time is None:
|
||||||
|
return parsed["window"]
|
||||||
|
return (parsed["window"], due_time)
|
||||||
|
raise vol.Invalid(
|
||||||
|
f"windows entry must be a window name or object, got {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _windows(value: object) -> list[str | tuple[str, str | None]]:
|
||||||
|
if type(value) is not list:
|
||||||
|
raise vol.Invalid(f"windows must be a list, got {value!r}")
|
||||||
|
if not value:
|
||||||
|
raise vol.Invalid("windows must not be empty")
|
||||||
|
return [_window_entry(entry) for entry in value]
|
||||||
|
|
||||||
|
|
||||||
|
def _int_list(value: object) -> list[int]:
|
||||||
|
if type(value) is not list:
|
||||||
|
raise vol.Invalid(f"expected a list of ints, got {value!r}")
|
||||||
|
return [_strict_int(item) for item in value]
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_dict(value: object) -> dict:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise vol.Invalid(f"rule must be a dict, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("instance_id"): _strict_int,
|
||||||
|
vol.Required("actor"): vol.In([ACTOR_PANEL, ACTOR_USER]),
|
||||||
|
vol.Optional("actor_child_id"): _strict_int,
|
||||||
|
vol.Optional("actor_user_id"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
|
||||||
|
|
||||||
|
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("title"): str,
|
||||||
|
vol.Required("rule"): _rule_dict,
|
||||||
|
vol.Required("assignee_child_ids"): vol.All(
|
||||||
|
_int_list, vol.Length(min=1)
|
||||||
|
),
|
||||||
|
vol.Required("windows"): _windows,
|
||||||
|
vol.Optional("description"): str,
|
||||||
|
vol.Optional("icon"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("definition_id"): _strict_int,
|
||||||
|
vol.Optional("title"): str,
|
||||||
|
vol.Optional("description"): vol.Any(str, None),
|
||||||
|
vol.Optional("icon"): vol.Any(str, None),
|
||||||
|
vol.Optional("rule"): _rule_dict,
|
||||||
|
vol.Optional("windows"): _windows,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("definition_id"): _strict_int,
|
||||||
|
vol.Required("is_active"): _strict_bool,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("child_id"): _strict_int,
|
||||||
|
vol.Required("cycle_length_weeks"): _strict_int,
|
||||||
|
vol.Required("anchor_date"): str,
|
||||||
|
vol.Required("pattern"): _presence_pattern,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("child_id"): _strict_int,
|
||||||
|
vol.Required("start_date"): str,
|
||||||
|
vol.Required("end_date"): str,
|
||||||
|
vol.Required("is_present"): _strict_bool,
|
||||||
|
vol.Optional("note"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("override_id"): _strict_int,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
|
||||||
|
|
||||||
|
SCHEMA_MANAGE_CHILD = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("action"): vol.In(
|
||||||
|
[
|
||||||
|
MANAGE_CHILD_CREATE,
|
||||||
|
MANAGE_CHILD_EDIT,
|
||||||
|
MANAGE_CHILD_SET_ACTIVE,
|
||||||
|
MANAGE_CHILD_REORDER,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
vol.Optional("child_id"): _strict_int,
|
||||||
|
vol.Optional("display_name"): str,
|
||||||
|
vol.Optional("colour"): str,
|
||||||
|
vol.Optional("avatar_ref"): str,
|
||||||
|
vol.Optional("sort_order"): _strict_int,
|
||||||
|
vol.Optional("is_active"): _strict_bool,
|
||||||
|
vol.Optional("ordered_ids"): _int_list,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
||||||
|
runtime = find_runtime(hass)
|
||||||
|
if runtime is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"NestQuest has no live database; cannot run this service"
|
||||||
|
)
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
|
||||||
|
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
||||||
|
time_zone = ZoneInfo(hass.config.time_zone)
|
||||||
|
today = datetime.datetime.now(time_zone).date()
|
||||||
|
value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||||
|
horizon_days = DEFAULT_HORIZON_DAYS
|
||||||
|
else:
|
||||||
|
horizon_days = value
|
||||||
|
return {"today": today, "horizon_days": horizon_days}
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||||
|
actor = call.data["actor"]
|
||||||
|
if actor == ACTOR_PANEL:
|
||||||
|
actor_child_id = call.data.get("actor_child_id")
|
||||||
|
if actor_child_id is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"actor_child_id is required when actor is panel"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"actor_source": ACTOR_PANEL,
|
||||||
|
"actor_child_id": actor_child_id,
|
||||||
|
"actor_user_id": None,
|
||||||
|
}
|
||||||
|
context = getattr(call, "context", None)
|
||||||
|
user_id = getattr(context, "user_id", None) if context is not None else None
|
||||||
|
if not user_id:
|
||||||
|
user_id = call.data.get("actor_user_id")
|
||||||
|
return {
|
||||||
|
"actor_source": ACTOR_USER,
|
||||||
|
"actor_user_id": user_id,
|
||||||
|
"actor_child_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _with_errors(handler: ServiceHandler) -> ServiceHandler:
|
||||||
|
async def _wrapped(call: Any) -> None:
|
||||||
|
try:
|
||||||
|
await handler(call)
|
||||||
|
except HomeAssistantError:
|
||||||
|
raise
|
||||||
|
except ValueError as err:
|
||||||
|
raise HomeAssistantError(str(err)) from err
|
||||||
|
|
||||||
|
return _wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def _complete_quest(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_runtime(hass, find_runtime).database
|
||||||
|
await completion_layer.complete_instance(
|
||||||
|
database,
|
||||||
|
call.data["instance_id"],
|
||||||
|
**_actor_kwargs(call),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _uncomplete_quest(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_runtime(hass, find_runtime).database
|
||||||
|
await completion_layer.uncomplete_instance(
|
||||||
|
database,
|
||||||
|
call.data["instance_id"],
|
||||||
|
**_actor_kwargs(call),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_quest_definition(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_runtime(hass, find_runtime).database
|
||||||
|
data = call.data
|
||||||
|
await quest_definitions_layer.create_quest_definition(
|
||||||
|
database,
|
||||||
|
data["title"],
|
||||||
|
ScheduleRule.from_dict(data["rule"]),
|
||||||
|
data["assignee_child_ids"],
|
||||||
|
data["windows"],
|
||||||
|
description=data.get("description"),
|
||||||
|
icon=data.get("icon"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_quest_definition(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
runtime = _require_runtime(hass, find_runtime)
|
||||||
|
data = call.data
|
||||||
|
kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
|
||||||
|
if "title" in data:
|
||||||
|
kwargs["title"] = data["title"]
|
||||||
|
if "description" in data:
|
||||||
|
kwargs["description"] = data["description"]
|
||||||
|
if "icon" in data:
|
||||||
|
kwargs["icon"] = data["icon"]
|
||||||
|
if "rule" in data:
|
||||||
|
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
|
||||||
|
if "windows" in data:
|
||||||
|
kwargs["windows"] = data["windows"]
|
||||||
|
await quest_definitions_layer.edit_quest_definition(
|
||||||
|
runtime.database, data["definition_id"], **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_quest_definition_active(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
runtime = _require_runtime(hass, find_runtime)
|
||||||
|
await quest_definitions_layer.set_quest_definition_active(
|
||||||
|
runtime.database,
|
||||||
|
call.data["definition_id"],
|
||||||
|
call.data["is_active"],
|
||||||
|
**_generation_kwargs(hass, runtime),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_presence_pattern(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
runtime = _require_runtime(hass, find_runtime)
|
||||||
|
data = call.data
|
||||||
|
schedule = PresenceSchedule(
|
||||||
|
data["child_id"],
|
||||||
|
data["cycle_length_weeks"],
|
||||||
|
data["anchor_date"],
|
||||||
|
data["pattern"],
|
||||||
|
)
|
||||||
|
await PresenceSchedulesDao(runtime.database).upsert_by_child(
|
||||||
|
schedule.child_id,
|
||||||
|
schedule.cycle_length_weeks,
|
||||||
|
schedule.anchor_date.isoformat(),
|
||||||
|
schedule.encode(),
|
||||||
|
)
|
||||||
|
await regenerate_for_child(
|
||||||
|
runtime.database,
|
||||||
|
schedule.child_id,
|
||||||
|
**_generation_kwargs(hass, runtime),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_presence_override(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
runtime = _require_runtime(hass, find_runtime)
|
||||||
|
data = call.data
|
||||||
|
await PresenceOverridesDao(runtime.database).create(
|
||||||
|
data["child_id"],
|
||||||
|
data["start_date"],
|
||||||
|
data["end_date"],
|
||||||
|
data["is_present"],
|
||||||
|
note=data.get("note"),
|
||||||
|
)
|
||||||
|
await regenerate_for_child(
|
||||||
|
runtime.database,
|
||||||
|
data["child_id"],
|
||||||
|
**_generation_kwargs(hass, runtime),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_presence_override(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
runtime = _require_runtime(hass, find_runtime)
|
||||||
|
dao = PresenceOverridesDao(runtime.database)
|
||||||
|
override_id = call.data["override_id"]
|
||||||
|
override = await dao.get(override_id)
|
||||||
|
if override is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"override_id: presence override {override_id} does not exist"
|
||||||
|
)
|
||||||
|
await dao.delete(override_id)
|
||||||
|
await regenerate_for_child(
|
||||||
|
runtime.database,
|
||||||
|
override.child_id,
|
||||||
|
**_generation_kwargs(hass, runtime),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
async def _export_history_csv(_call: Any) -> None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"CSV history export is not implemented until Feature 13"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _manage_child(
|
||||||
|
hass: HomeAssistant, find_runtime: FindRuntime
|
||||||
|
) -> ServiceHandler:
|
||||||
|
async def _handler(call: Any) -> None:
|
||||||
|
database = _require_runtime(hass, find_runtime).database
|
||||||
|
data = call.data
|
||||||
|
action = data["action"]
|
||||||
|
if action == MANAGE_CHILD_CREATE:
|
||||||
|
display_name = data.get("display_name")
|
||||||
|
if display_name is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"display_name is required when action is create"
|
||||||
|
)
|
||||||
|
kwargs: dict[str, Any] = {}
|
||||||
|
if "colour" in data:
|
||||||
|
kwargs["colour"] = data["colour"]
|
||||||
|
if "avatar_ref" in data:
|
||||||
|
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||||
|
if "sort_order" in data:
|
||||||
|
kwargs["sort_order"] = data["sort_order"]
|
||||||
|
await children_layer.create_child(
|
||||||
|
database, display_name, **kwargs
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if action == MANAGE_CHILD_EDIT:
|
||||||
|
child_id = data.get("child_id")
|
||||||
|
if child_id is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"child_id is required when action is edit"
|
||||||
|
)
|
||||||
|
kwargs = {}
|
||||||
|
if "display_name" in data:
|
||||||
|
kwargs["display_name"] = data["display_name"]
|
||||||
|
if "colour" in data:
|
||||||
|
kwargs["colour"] = data["colour"]
|
||||||
|
if "avatar_ref" in data:
|
||||||
|
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||||
|
if "sort_order" in data:
|
||||||
|
kwargs["sort_order"] = data["sort_order"]
|
||||||
|
await children_layer.edit_child(database, child_id, **kwargs)
|
||||||
|
return
|
||||||
|
if action == MANAGE_CHILD_SET_ACTIVE:
|
||||||
|
child_id = data.get("child_id")
|
||||||
|
is_active = data.get("is_active")
|
||||||
|
if child_id is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"child_id is required when action is set_active"
|
||||||
|
)
|
||||||
|
if is_active is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"is_active is required when action is set_active"
|
||||||
|
)
|
||||||
|
await children_layer.set_child_active(
|
||||||
|
database, child_id, is_active
|
||||||
|
)
|
||||||
|
return
|
||||||
|
ordered_ids = data.get("ordered_ids")
|
||||||
|
if ordered_ids is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"ordered_ids is required when action is reorder"
|
||||||
|
)
|
||||||
|
await children_layer.reorder_children(database, ordered_ids)
|
||||||
|
|
||||||
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
|
def async_register_services(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
*,
|
||||||
|
find_runtime: FindRuntime,
|
||||||
|
regenerate_handler: ServiceHandler,
|
||||||
|
) -> None:
|
||||||
|
"""Register every canonical service once at domain scope.
|
||||||
|
|
||||||
|
Every service — regenerate included — passes through the Feature
|
||||||
|
09 permission gate before its handler runs; the gate reads
|
||||||
|
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
|
||||||
|
registered exempt from the policy.
|
||||||
|
"""
|
||||||
|
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||||
|
return
|
||||||
|
gate = permission_gate
|
||||||
|
specs: list[tuple[str, ServiceHandler, object]] = [
|
||||||
|
(SERVICE_REGENERATE, regenerate_handler, None),
|
||||||
|
(
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
_complete_quest(hass, find_runtime),
|
||||||
|
SCHEMA_COMPLETE_QUEST,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
_uncomplete_quest(hass, find_runtime),
|
||||||
|
SCHEMA_UNCOMPLETE_QUEST,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
_create_quest_definition(hass, find_runtime),
|
||||||
|
SCHEMA_CREATE_QUEST_DEFINITION,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||||
|
_update_quest_definition(hass, find_runtime),
|
||||||
|
SCHEMA_UPDATE_QUEST_DEFINITION,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
_set_quest_definition_active(hass, find_runtime),
|
||||||
|
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN,
|
||||||
|
_set_presence_pattern(hass, find_runtime),
|
||||||
|
SCHEMA_SET_PRESENCE_PATTERN,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
_create_presence_override(hass, find_runtime),
|
||||||
|
SCHEMA_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
_delete_presence_override(hass, find_runtime),
|
||||||
|
SCHEMA_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV,
|
||||||
|
_export_history_csv,
|
||||||
|
SCHEMA_EXPORT_HISTORY_CSV,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
_manage_child(hass, find_runtime),
|
||||||
|
SCHEMA_MANAGE_CHILD,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _database_of(_call: Any) -> Any:
|
||||||
|
return _require_runtime(hass, find_runtime).database
|
||||||
|
|
||||||
|
for service, handler, schema in specs:
|
||||||
|
gated = gate(
|
||||||
|
service, SERVICE_POLICY[service], _database_of
|
||||||
|
)(handler)
|
||||||
|
hass.services.async_register(DOMAIN, service, gated, schema=schema)
|
||||||
|
|
||||||
|
|
||||||
|
def async_deregister_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Remove every canonical service when the last entry unloads."""
|
||||||
|
for service in DOMAIN_SERVICES:
|
||||||
|
if hass.services.has_service(DOMAIN, service):
|
||||||
|
hass.services.async_remove(DOMAIN, service)
|
||||||
@@ -9,4 +9,318 @@ regenerate:
|
|||||||
horizon, using the same idempotent materialization as the daily rollover
|
horizon, using the same idempotent materialization as the daily rollover
|
||||||
and the startup backfill. Safe to call at any time: a re-run refreshes the
|
and the startup backfill. Safe to call at any time: a re-run refreshes the
|
||||||
same rows instead of duplicating them, and completed instances are never
|
same rows instead of duplicating them, and completed instances are never
|
||||||
touched.
|
touched.
|
||||||
|
|
||||||
|
complete_quest:
|
||||||
|
name: Complete quest
|
||||||
|
description: Mark a quest instance as completed.
|
||||||
|
fields:
|
||||||
|
instance_id:
|
||||||
|
name: Instance ID
|
||||||
|
description: The quest instance to complete.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
actor:
|
||||||
|
name: Actor
|
||||||
|
description: Who completed the quest (panel or user).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- panel
|
||||||
|
- user
|
||||||
|
actor_child_id:
|
||||||
|
name: Actor child ID
|
||||||
|
description: The tapped child profile. Required when actor is panel.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
actor_user_id:
|
||||||
|
name: Actor user ID
|
||||||
|
description: Home Assistant user id when actor is user and no call context is present.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
uncomplete_quest:
|
||||||
|
name: Uncomplete quest
|
||||||
|
description: Undo a quest instance completion.
|
||||||
|
fields:
|
||||||
|
instance_id:
|
||||||
|
name: Instance ID
|
||||||
|
description: The quest instance to uncomplete.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
actor:
|
||||||
|
name: Actor
|
||||||
|
description: Who is undoing the completion (panel or user).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- panel
|
||||||
|
- user
|
||||||
|
actor_child_id:
|
||||||
|
name: Actor child ID
|
||||||
|
description: The tapped child profile. Required when actor is panel.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
actor_user_id:
|
||||||
|
name: Actor user ID
|
||||||
|
description: Home Assistant user id when actor is user and no call context is present.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
create_quest_definition:
|
||||||
|
name: Create quest definition
|
||||||
|
description: Create a quest definition with its schedule rule, assignees and windows.
|
||||||
|
fields:
|
||||||
|
title:
|
||||||
|
name: Title
|
||||||
|
description: Display title for the quest.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
rule:
|
||||||
|
name: Rule
|
||||||
|
description: Recurrence rule as a mapping (rule_type plus shape fields).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
assignee_child_ids:
|
||||||
|
name: Assignee child IDs
|
||||||
|
description: Child ids this definition is assigned to.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
windows:
|
||||||
|
name: Windows
|
||||||
|
description: Day windows (morning, afternoon, evening), optionally with a due time.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
description:
|
||||||
|
name: Description
|
||||||
|
description: Optional longer description.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
icon:
|
||||||
|
name: Icon
|
||||||
|
description: Optional icon name.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
update_quest_definition:
|
||||||
|
name: Update quest definition
|
||||||
|
description: Edit a quest definition's title, description, icon, rule or windows.
|
||||||
|
fields:
|
||||||
|
definition_id:
|
||||||
|
name: Definition ID
|
||||||
|
description: The quest definition to update.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
title:
|
||||||
|
name: Title
|
||||||
|
description: Replacement display title.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
description:
|
||||||
|
name: Description
|
||||||
|
description: Replacement description, or null to clear.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
icon:
|
||||||
|
name: Icon
|
||||||
|
description: Replacement icon, or null to clear.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
rule:
|
||||||
|
name: Rule
|
||||||
|
description: Replacement recurrence rule as a mapping.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
windows:
|
||||||
|
name: Windows
|
||||||
|
description: Replacement day windows.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
|
||||||
|
set_quest_definition_active:
|
||||||
|
name: Set quest definition active
|
||||||
|
description: Deactivate or reactivate a quest definition.
|
||||||
|
fields:
|
||||||
|
definition_id:
|
||||||
|
name: Definition ID
|
||||||
|
description: The quest definition to update.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
is_active:
|
||||||
|
name: Active
|
||||||
|
description: Whether the definition should generate future instances.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
set_presence_pattern:
|
||||||
|
name: Set presence pattern
|
||||||
|
description: Upsert a child's repeating presence schedule.
|
||||||
|
fields:
|
||||||
|
child_id:
|
||||||
|
name: Child ID
|
||||||
|
description: The child whose schedule is being set.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
cycle_length_weeks:
|
||||||
|
name: Cycle length weeks
|
||||||
|
description: Number of weeks in the repeating cycle (1 to 4).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
min: 1
|
||||||
|
max: 4
|
||||||
|
anchor_date:
|
||||||
|
name: Anchor date
|
||||||
|
description: Cycle anchor as a YYYY-MM-DD date.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
date:
|
||||||
|
pattern:
|
||||||
|
name: Pattern
|
||||||
|
description: Mapping of week index to present weekday ints (Monday is 0).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
|
||||||
|
create_presence_override:
|
||||||
|
name: Create presence override
|
||||||
|
description: Create a date-range presence override for a child.
|
||||||
|
fields:
|
||||||
|
child_id:
|
||||||
|
name: Child ID
|
||||||
|
description: The child the override applies to.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
start_date:
|
||||||
|
name: Start date
|
||||||
|
description: Inclusive start date (YYYY-MM-DD).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
date:
|
||||||
|
end_date:
|
||||||
|
name: End date
|
||||||
|
description: Inclusive end date (YYYY-MM-DD).
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
date:
|
||||||
|
is_present:
|
||||||
|
name: Present
|
||||||
|
description: Whether the child is present for this range.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
note:
|
||||||
|
name: Note
|
||||||
|
description: Optional note stored with the override.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
delete_presence_override:
|
||||||
|
name: Delete presence override
|
||||||
|
description: Delete a presence override by id.
|
||||||
|
fields:
|
||||||
|
override_id:
|
||||||
|
name: Override ID
|
||||||
|
description: The override to delete.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
|
||||||
|
export_history_csv:
|
||||||
|
name: Export history CSV
|
||||||
|
description: Export completion history as CSV. Not implemented until Feature 13.
|
||||||
|
|
||||||
|
manage_child:
|
||||||
|
name: Manage child
|
||||||
|
description: Create, edit, activate or reorder child profiles.
|
||||||
|
fields:
|
||||||
|
action:
|
||||||
|
name: Action
|
||||||
|
description: create, edit, set_active, or reorder.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- create
|
||||||
|
- edit
|
||||||
|
- set_active
|
||||||
|
- reorder
|
||||||
|
child_id:
|
||||||
|
name: Child ID
|
||||||
|
description: Required for edit and set_active.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
display_name:
|
||||||
|
name: Display name
|
||||||
|
description: Required for create; optional for edit.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
colour:
|
||||||
|
name: Colour
|
||||||
|
description: Optional colour string.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
avatar_ref:
|
||||||
|
name: Avatar
|
||||||
|
description: Optional avatar reference.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
sort_order:
|
||||||
|
name: Sort order
|
||||||
|
description: Optional panel sort order.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
is_active:
|
||||||
|
name: Active
|
||||||
|
description: Required for set_active.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
ordered_ids:
|
||||||
|
name: Ordered IDs
|
||||||
|
description: Complete permutation of child ids. Required for reorder.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
|||||||
+31
-6
@@ -177,7 +177,11 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
|
|||||||
_data_entry_flow_mock.FlowResult = dict
|
_data_entry_flow_mock.FlowResult = dict
|
||||||
|
|
||||||
|
|
||||||
class ConfigEntryNotReady(Exception):
|
class HomeAssistantError(Exception):
|
||||||
|
"""Stand-in mirroring homeassistant.exceptions.HomeAssistantError."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigEntryNotReady(HomeAssistantError):
|
||||||
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
|
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
|
||||||
|
|
||||||
HA retries setup when this is raised; the tests assert the corrupt
|
HA retries setup when this is raised; the tests assert the corrupt
|
||||||
@@ -185,7 +189,13 @@ class ConfigEntryNotReady(Exception):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Unauthorized(HomeAssistantError):
|
||||||
|
"""Stand-in mirroring homeassistant.exceptions.Unauthorized."""
|
||||||
|
|
||||||
|
|
||||||
|
_exceptions_mock.HomeAssistantError = HomeAssistantError
|
||||||
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||||
|
_exceptions_mock.Unauthorized = Unauthorized
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -325,7 +335,7 @@ class ServiceRegistry:
|
|||||||
self._services: dict[tuple[str, str], object] = {}
|
self._services: dict[tuple[str, str], object] = {}
|
||||||
|
|
||||||
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
||||||
self._services[(domain, service)] = func
|
self._services[(domain, service)] = (func, schema)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def has_service(self, domain, service):
|
def has_service(self, domain, service):
|
||||||
@@ -335,11 +345,26 @@ class ServiceRegistry:
|
|||||||
self._services.pop((domain, service), None)
|
self._services.pop((domain, service), None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def call(self, domain, service, data=None):
|
async def call(self, domain, service, data=None, context=None):
|
||||||
func = self._services.get((domain, service))
|
registered = self._services.get((domain, service))
|
||||||
if func is None:
|
if registered is None:
|
||||||
raise KeyError(f"service {domain}.{service} not registered")
|
raise KeyError(f"service {domain}.{service} not registered")
|
||||||
call = SimpleNamespace(domain=domain, service=service, data=data or {})
|
func, schema = registered
|
||||||
|
payload = dict(data or {})
|
||||||
|
if schema is not None:
|
||||||
|
payload = schema(payload)
|
||||||
|
if context is None:
|
||||||
|
context_obj = SimpleNamespace(user_id=None)
|
||||||
|
elif isinstance(context, dict):
|
||||||
|
context_obj = SimpleNamespace(**context)
|
||||||
|
else:
|
||||||
|
context_obj = context
|
||||||
|
call = SimpleNamespace(
|
||||||
|
domain=domain,
|
||||||
|
service=service,
|
||||||
|
data=payload,
|
||||||
|
context=context_obj,
|
||||||
|
)
|
||||||
result = func(call)
|
result = func(call)
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
|
|||||||
@@ -1361,3 +1361,90 @@ def test_list_missed_for_child_rejects_non_integer_id() -> None:
|
|||||||
await list_missed_for_child(object(), True, D1, D1)
|
await list_missed_for_child(object(), True, D1, D1)
|
||||||
|
|
||||||
_run(_body())
|
_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,6 +455,20 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
|
|||||||
assert _with_db(tmp_path, "override-list-unknown.db")(_body) == []
|
assert _with_db(tmp_path, "override-list-unknown.db")(_body) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_get_round_trip(tmp_path) -> None:
|
||||||
|
async def _body(database, schedules, overrides, children, child):
|
||||||
|
record = await overrides.create(
|
||||||
|
child.id, "2026-09-10", "2026-09-11", True, note="camp"
|
||||||
|
)
|
||||||
|
found = await overrides.get(record.id)
|
||||||
|
assert found == record
|
||||||
|
assert await overrides.get(999) is None
|
||||||
|
return found
|
||||||
|
|
||||||
|
found = _with_db(tmp_path, "override-get.db")(_body)
|
||||||
|
assert found.note == "camp"
|
||||||
|
|
||||||
|
|
||||||
def test_override_delete_round_trip(tmp_path) -> None:
|
def test_override_delete_round_trip(tmp_path) -> None:
|
||||||
async def _body(database, schedules, overrides, children, child):
|
async def _body(database, schedules, overrides, children, child):
|
||||||
record = await overrides.create(
|
record = await overrides.create(
|
||||||
|
|||||||
+21
-8
@@ -9,6 +9,7 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
|||||||
|
|
||||||
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
@@ -16,6 +17,13 @@ from custom_components.nestquest.const import (
|
|||||||
DOMAIN as DOMAIN_CONST,
|
DOMAIN as DOMAIN_CONST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ADMIN_ID = "admin-1"
|
||||||
|
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_data():
|
||||||
|
return {CONF_ADMIN_USER_IDS: [ADMIN_ID]}
|
||||||
|
|
||||||
|
|
||||||
def _wire(entry, registry):
|
def _wire(entry, registry):
|
||||||
return wire_entry_to_registry(entry, registry)
|
return wire_entry_to_registry(entry, registry)
|
||||||
@@ -546,7 +554,7 @@ async def test_regenerate_service_registered_and_materializes(
|
|||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
entry = _wire(make_entry(), hass.registry)
|
entry = _wire(make_entry(data=_admin_data()), hass.registry)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
@@ -555,7 +563,7 @@ async def test_regenerate_service_registered_and_materializes(
|
|||||||
database = entry.runtime_data.database
|
database = entry.runtime_data.database
|
||||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||||
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
|
||||||
today = _local_today(hass)
|
today = _local_today(hass)
|
||||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
@@ -574,14 +582,15 @@ async def test_regenerate_service_uses_configured_horizon_days(
|
|||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
entry = _wire(
|
entry = _wire(
|
||||||
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
|
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
|
||||||
|
hass.registry,
|
||||||
)
|
)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
|
||||||
database = entry.runtime_data.database
|
database = entry.runtime_data.database
|
||||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
||||||
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
|
||||||
today = _local_today(hass)
|
today = _local_today(hass)
|
||||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
end = (today + datetime.timedelta(days=5)).isoformat()
|
||||||
@@ -622,8 +631,12 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
|
|||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
|
entry_a = _wire(
|
||||||
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
|
make_entry(entry_id="entry_a", data=_admin_data()), hass.registry
|
||||||
|
)
|
||||||
|
entry_b = _wire(
|
||||||
|
make_entry(entry_id="entry_b", data=_admin_data()), hass.registry
|
||||||
|
)
|
||||||
|
|
||||||
assert await async_setup_entry(hass, entry_a) is True
|
assert await async_setup_entry(hass, entry_a) is True
|
||||||
assert await async_setup_entry(hass, entry_b) is True
|
assert await async_setup_entry(hass, entry_b) is True
|
||||||
@@ -634,7 +647,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
|
|||||||
child_id = await _seed_daily_child_and_definition(
|
child_id = await _seed_daily_child_and_definition(
|
||||||
database_b, _local_today(hass)
|
database_b, _local_today(hass)
|
||||||
)
|
)
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
today = _local_today(hass)
|
today = _local_today(hass)
|
||||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
records = await QuestInstancesDao(database_b).list_by_date_range(
|
records = await QuestInstancesDao(database_b).list_by_date_range(
|
||||||
@@ -647,7 +660,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
|
|||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
||||||
|
|
||||||
# It still works for the remaining entry.
|
# It still works for the remaining entry.
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
|
||||||
# Unloading the final entry removes the domain-global service.
|
# Unloading the final entry removes the domain-global service.
|
||||||
assert await async_unload_entry(hass, entry_b) is True
|
assert await async_unload_entry(hass, entry_b) is True
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Tests for the Feature 09 admin-only versus open service registry."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from custom_components.nestquest.const import (
|
||||||
|
DOMAIN_SERVICES,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.service_policy import (
|
||||||
|
ADMIN_ONLY,
|
||||||
|
OPEN,
|
||||||
|
SERVICE_POLICY,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_covers_exactly_the_registered_services() -> None:
|
||||||
|
assert frozenset(SERVICE_POLICY) == frozenset(DOMAIN_SERVICES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_complete_quest_is_open() -> None:
|
||||||
|
open_services = {
|
||||||
|
name for name, access in SERVICE_POLICY.items() if access == OPEN
|
||||||
|
}
|
||||||
|
assert open_services == {SERVICE_COMPLETE_QUEST}
|
||||||
|
assert all(
|
||||||
|
access == ADMIN_ONLY
|
||||||
|
for name, access in SERVICE_POLICY.items()
|
||||||
|
if name != SERVICE_COMPLETE_QUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_values_are_only_open_or_admin_only() -> None:
|
||||||
|
assert set(SERVICE_POLICY.values()) <= {OPEN, ADMIN_ONLY}
|
||||||
+560
-8
@@ -1,9 +1,33 @@
|
|||||||
"""Tests for the NestQuest services.yaml declaration file."""
|
"""Tests for NestQuest service declarations and domain-global handlers."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import voluptuous as vol
|
||||||
|
from homeassistant.exceptions import HomeAssistantError, Unauthorized
|
||||||
|
|
||||||
|
from conftest import wire_entry_to_registry
|
||||||
|
|
||||||
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||||
|
from custom_components.nestquest.const import (
|
||||||
|
CONF_ADMIN_USER_IDS,
|
||||||
|
DOMAIN,
|
||||||
|
DOMAIN_SERVICES,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
SERVICE_EXPORT_HISTORY_CSV,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
SERVICE_REGENERATE,
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.service_policy import ADMIN_ONLY, SERVICE_POLICY
|
||||||
|
|
||||||
SERVICES_PATH = (
|
SERVICES_PATH = (
|
||||||
Path(__file__).parent.parent
|
Path(__file__).parent.parent
|
||||||
/ "custom_components"
|
/ "custom_components"
|
||||||
@@ -12,10 +36,47 @@ SERVICES_PATH = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wire(entry, registry):
|
||||||
|
return wire_entry_to_registry(entry, registry)
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_ID = "admin-1"
|
||||||
|
ADMIN_CTX = {"user_id": ADMIN_ID}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_admin_entry(make_entry):
|
||||||
|
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||||
|
|
||||||
|
return make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]})
|
||||||
|
|
||||||
|
|
||||||
|
def _service_section(text: str, name: str) -> str:
|
||||||
|
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
|
||||||
|
assert match is not None, f"services.yaml must declare {name!r}"
|
||||||
|
section = text[match.end() :]
|
||||||
|
next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE)
|
||||||
|
if next_service is not None:
|
||||||
|
section = section[: next_service.start()]
|
||||||
|
return section
|
||||||
|
|
||||||
|
|
||||||
def test_services_yaml_exists() -> None:
|
def test_services_yaml_exists() -> None:
|
||||||
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
|
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_yaml_declares_every_canonical_service() -> None:
|
||||||
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
|
declared = re.findall(r"^(?![#\s])(\w+):\s*$", text, re.MULTILINE)
|
||||||
|
assert declared == list(DOMAIN_SERVICES) or set(declared) == set(
|
||||||
|
DOMAIN_SERVICES
|
||||||
|
)
|
||||||
|
for name in DOMAIN_SERVICES:
|
||||||
|
section = _service_section(text, name)
|
||||||
|
assert re.search(r"description:", section), (
|
||||||
|
f"{name} must carry a description"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_services_yaml_declares_regenerate() -> None:
|
def test_services_yaml_declares_regenerate() -> None:
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
|
||||||
@@ -27,15 +88,506 @@ def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
|
|||||||
"""The regenerate service is documented with a clear description and no
|
"""The regenerate service is documented with a clear description and no
|
||||||
required fields (there is no ``fields`` block under it)."""
|
required fields (there is no ``fields`` block under it)."""
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
match = re.search(r"^regenerate:\s*$", text, re.MULTILINE)
|
section = _service_section(text, "regenerate")
|
||||||
assert match is not None
|
|
||||||
section = text[match.end():]
|
|
||||||
next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE)
|
|
||||||
if next_service is not None:
|
|
||||||
section = section[: next_service.start()]
|
|
||||||
assert re.search(r"description:", section), (
|
assert re.search(r"description:", section), (
|
||||||
"regenerate must carry a description"
|
"regenerate must carry a description"
|
||||||
)
|
)
|
||||||
assert "fields:" not in section, (
|
assert "fields:" not in section, (
|
||||||
"regenerate declares no required fields"
|
"regenerate declares no required fields"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_yaml_other_services_declare_fields() -> None:
|
||||||
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
|
for name in DOMAIN_SERVICES:
|
||||||
|
if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV):
|
||||||
|
continue
|
||||||
|
section = _service_section(text, name)
|
||||||
|
assert "fields:" in section, f"{name} must declare fields"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_all_canonical_services_registered_after_setup(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
for service in DOMAIN_SERVICES:
|
||||||
|
assert hass.services.has_service(DOMAIN, service), service
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None:
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
for service in DOMAIN_SERVICES:
|
||||||
|
assert not hass.services.has_service(DOMAIN, service), service
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_one_of_two_entries_keeps_services(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
|
||||||
|
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry_a) is True
|
||||||
|
assert await async_setup_entry(hass, entry_b) is True
|
||||||
|
assert await async_unload_entry(hass, entry_a) is True
|
||||||
|
for service in DOMAIN_SERVICES:
|
||||||
|
assert hass.services.has_service(DOMAIN, service), service
|
||||||
|
assert await async_unload_entry(hass, entry_b) is True
|
||||||
|
for service in DOMAIN_SERVICES:
|
||||||
|
assert not hass.services.has_service(DOMAIN, service), service
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manage_child_create_persists_child(hass, make_entry) -> None:
|
||||||
|
from custom_components.nestquest.children import list_children
|
||||||
|
|
||||||
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
children = await list_children(entry.runtime_data.database)
|
||||||
|
assert [child.display_name for child in children] == ["Ada"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_and_set_quest_definition_active(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
from custom_components.nestquest.children import list_children
|
||||||
|
from custom_components.nestquest.quest_definitions import (
|
||||||
|
list_active_definitions,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.dao_rules import QuestDefinitionsDao
|
||||||
|
|
||||||
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
child = (await list_children(entry.runtime_data.database))[0]
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
{
|
||||||
|
"title": "Brush teeth",
|
||||||
|
"rule": {"rule_type": "daily"},
|
||||||
|
"assignee_child_ids": [child.id],
|
||||||
|
"windows": ["morning"],
|
||||||
|
},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
active = await list_active_definitions(entry.runtime_data.database)
|
||||||
|
assert len(active) == 1
|
||||||
|
definition_id = active[0].definition.id
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||||
|
{"definition_id": definition_id, "is_active": False},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
assert await list_active_definitions(entry.runtime_data.database) == []
|
||||||
|
stored = await QuestDefinitionsDao(entry.runtime_data.database).get(
|
||||||
|
definition_id
|
||||||
|
)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.is_active is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_complete_and_uncomplete_quest_call_business_layer(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.children import list_children
|
||||||
|
from custom_components.nestquest.completion import instance_state
|
||||||
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||||
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
child = (await list_children(entry.runtime_data.database))[0]
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
{
|
||||||
|
"title": "Brush teeth",
|
||||||
|
"rule": {"rule_type": "daily"},
|
||||||
|
"assignee_child_ids": [child.id],
|
||||||
|
"windows": ["morning"],
|
||||||
|
},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
today = datetime.date.today().isoformat()
|
||||||
|
end = (
|
||||||
|
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
records = await QuestInstancesDao(
|
||||||
|
entry.runtime_data.database
|
||||||
|
).list_by_date_range(child.id, today, end)
|
||||||
|
assert records
|
||||||
|
instance_id = records[0].id
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
{
|
||||||
|
"instance_id": instance_id,
|
||||||
|
"actor": "panel",
|
||||||
|
"actor_child_id": child.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await instance_state(entry.runtime_data.database, instance_id)
|
||||||
|
== "done"
|
||||||
|
)
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
|
{
|
||||||
|
"instance_id": instance_id,
|
||||||
|
"actor": "user",
|
||||||
|
"actor_user_id": ADMIN_ID,
|
||||||
|
},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await instance_state(entry.runtime_data.database, instance_id)
|
||||||
|
== "open"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_presence_pattern_regenerates_child_instances(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.children import list_children
|
||||||
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||||
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
child = (await list_children(entry.runtime_data.database))[0]
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
{
|
||||||
|
"title": "Brush teeth",
|
||||||
|
"rule": {"rule_type": "daily"},
|
||||||
|
"assignee_child_ids": [child.id],
|
||||||
|
"windows": ["morning"],
|
||||||
|
},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
today = datetime.date.today().isoformat()
|
||||||
|
end = (
|
||||||
|
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
dao = QuestInstancesDao(entry.runtime_data.database)
|
||||||
|
assert await dao.list_by_date_range(child.id, today, end)
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SET_PRESENCE_PATTERN,
|
||||||
|
{
|
||||||
|
"child_id": child.id,
|
||||||
|
"cycle_length_weeks": 1,
|
||||||
|
"anchor_date": today,
|
||||||
|
"pattern": {0: []},
|
||||||
|
},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
assert await dao.list_by_date_range(child.id, today, end) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_presence_override_create_and_delete_regenerate(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.children import list_children
|
||||||
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||||
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
from custom_components.nestquest.dao_presence import PresenceOverridesDao
|
||||||
|
|
||||||
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
child = (await list_children(entry.runtime_data.database))[0]
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
{
|
||||||
|
"title": "Brush teeth",
|
||||||
|
"rule": {"rule_type": "daily"},
|
||||||
|
"assignee_child_ids": [child.id],
|
||||||
|
"windows": ["morning"],
|
||||||
|
},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
today = datetime.date.today()
|
||||||
|
end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
dao = QuestInstancesDao(entry.runtime_data.database)
|
||||||
|
before = await dao.list_by_date_range(
|
||||||
|
child.id, today.isoformat(), end.isoformat()
|
||||||
|
)
|
||||||
|
assert before
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||||
|
{
|
||||||
|
"child_id": child.id,
|
||||||
|
"start_date": today.isoformat(),
|
||||||
|
"end_date": end.isoformat(),
|
||||||
|
"is_present": False,
|
||||||
|
},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await dao.list_by_date_range(
|
||||||
|
child.id, today.isoformat(), end.isoformat()
|
||||||
|
)
|
||||||
|
== []
|
||||||
|
)
|
||||||
|
override = (
|
||||||
|
await PresenceOverridesDao(
|
||||||
|
entry.runtime_data.database
|
||||||
|
).list_by_child_and_range(
|
||||||
|
child.id, today.isoformat(), end.isoformat()
|
||||||
|
)
|
||||||
|
)[0]
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||||
|
{"override_id": override.id},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
after = await dao.list_by_date_range(
|
||||||
|
child.id, today.isoformat(), end.isoformat()
|
||||||
|
)
|
||||||
|
assert len(after) == len(before)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_history_csv_raises_home_assistant_error(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
with pytest.raises(HomeAssistantError, match="Feature 13"):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN, SERVICE_EXPORT_HISTORY_CSV, context=ADMIN_CTX
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
|
||||||
|
await hass.services.call(DOMAIN, SERVICE_COMPLETE_QUEST, {})
|
||||||
|
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
{"instance_id": True, "actor": "panel", "actor_child_id": 1},
|
||||||
|
)
|
||||||
|
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_only_service_denies_non_admin(hass, make_entry) -> None:
|
||||||
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
with pytest.raises(Unauthorized):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
context={"user_id": "kiosk-panel"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_only_service_denies_missing_user_context(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
with pytest.raises(Unauthorized):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_only_service_denies_admin_on_empty_allowlist(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""Even the allowlisted id is denied when the allowlist is empty."""
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
with pytest.raises(Unauthorized):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unauthorized_is_raised_before_handler_runs(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
from custom_components.nestquest.children import list_children
|
||||||
|
|
||||||
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
with pytest.raises(Unauthorized):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
context={"user_id": "kiosk-panel"},
|
||||||
|
)
|
||||||
|
assert await list_children(entry.runtime_data.database) == []
|
||||||
|
|
||||||
|
|
||||||
|
_MINIMAL_PAYLOADS = {
|
||||||
|
"uncomplete_quest": {"instance_id": 1, "actor": "user"},
|
||||||
|
"create_quest_definition": {
|
||||||
|
"title": "T",
|
||||||
|
"rule": {"rule_type": "daily"},
|
||||||
|
"assignee_child_ids": [1],
|
||||||
|
"windows": ["morning"],
|
||||||
|
},
|
||||||
|
"update_quest_definition": {"definition_id": 1},
|
||||||
|
"set_quest_definition_active": {"definition_id": 1, "is_active": True},
|
||||||
|
"set_presence_pattern": {
|
||||||
|
"child_id": 1,
|
||||||
|
"cycle_length_weeks": 1,
|
||||||
|
"anchor_date": "2026-09-19",
|
||||||
|
"pattern": {0: [0]},
|
||||||
|
},
|
||||||
|
"create_presence_override": {
|
||||||
|
"child_id": 1,
|
||||||
|
"start_date": "2026-09-19",
|
||||||
|
"end_date": "2026-09-19",
|
||||||
|
"is_present": False,
|
||||||
|
},
|
||||||
|
"delete_presence_override": {"override_id": 1},
|
||||||
|
"export_history_csv": {},
|
||||||
|
"manage_child": {"action": "create", "display_name": "Ada"},
|
||||||
|
"regenerate": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
_ADMIN_ONLY_SERVICES = [
|
||||||
|
name for name, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("service", _ADMIN_ONLY_SERVICES)
|
||||||
|
async def test_admin_only_service_fails_closed_without_user_context(
|
||||||
|
hass, make_entry, service
|
||||||
|
) -> None:
|
||||||
|
"""A call with no user_id is denied on every admin-only service."""
|
||||||
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
with pytest.raises(Unauthorized):
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN, service, dict(_MINIMAL_PAYLOADS[service])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_complete_quest_proceeds_without_user_context(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""The open service needs no user context: the kiosk panel taps a
|
||||||
|
child profile and the call goes through with no HA user at all."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.children import list_children
|
||||||
|
from custom_components.nestquest.completion import instance_state
|
||||||
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||||
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
|
|
||||||
|
entry = _wire(_make_admin_entry(make_entry), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_MANAGE_CHILD,
|
||||||
|
{"action": "create", "display_name": "Ada"},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
child = (await list_children(entry.runtime_data.database))[0]
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CREATE_QUEST_DEFINITION,
|
||||||
|
{
|
||||||
|
"title": "Brush teeth",
|
||||||
|
"rule": {"rule_type": "daily"},
|
||||||
|
"assignee_child_ids": [child.id],
|
||||||
|
"windows": ["morning"],
|
||||||
|
},
|
||||||
|
context=ADMIN_CTX,
|
||||||
|
)
|
||||||
|
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
||||||
|
today = datetime.date.today().isoformat()
|
||||||
|
end = (
|
||||||
|
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
instance_id = (
|
||||||
|
await QuestInstancesDao(
|
||||||
|
entry.runtime_data.database
|
||||||
|
).list_by_date_range(child.id, today, end)
|
||||||
|
)[0].id
|
||||||
|
await hass.services.call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await instance_state(entry.runtime_data.database, instance_id)
|
||||||
|
== "done"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scheduled_internals_never_call_the_service_layer(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""The daily rollover and startup backfill run the materialization
|
||||||
|
as direct function calls; they never widen the service gate."""
|
||||||
|
import custom_components.nestquest as nestquest
|
||||||
|
|
||||||
|
assert not hasattr(nestquest, "_run_horizon_materialization_service")
|
||||||
|
source = Path(nestquest.__file__).read_text(encoding="utf-8")
|
||||||
|
assert "hass.services.call" not in source, (
|
||||||
|
"scheduled internals must call the materialization directly, "
|
||||||
|
"never through hass.services.call"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user