Task d04f5bf3: Remove the HA admin services and permission gate (#171)
This commit was merged in pull request #171.
This commit is contained in:
@@ -282,9 +282,10 @@ async def _run_horizon_materialization(
|
|||||||
The walk itself runs through :func:`~.materialize.materialize`, whose DB
|
The walk itself runs through :func:`~.materialize.materialize`, whose DB
|
||||||
access is executor-only.
|
access is executor-only.
|
||||||
|
|
||||||
This is the ONE generation path: the startup backfill, the daily rollover
|
This is the ONE generation path inside the integration: the
|
||||||
listener and the ``regenerate`` service all funnel through it, so there is
|
startup backfill and the daily rollover listener both funnel
|
||||||
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
|
through it, so there is no duplicated materialization logic.
|
||||||
|
``settings`` (REQUIRED — there is no
|
||||||
silent default; every caller already passes the entry's validated
|
silent default; every caller already passes the entry's validated
|
||||||
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
|
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
|
||||||
window itself comes from :meth:`settings.horizon_window` so the helper is
|
window itself comes from :meth:`settings.horizon_window` so the helper is
|
||||||
@@ -326,13 +327,10 @@ def _register_day_rollover_listener(
|
|||||||
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||||
"""Return a live runtime record with a connected database, or None.
|
"""Return a live runtime record with a connected database, or None.
|
||||||
|
|
||||||
The domain-global services (e.g. ``regenerate``) have no database of
|
The domain-global service (the panel completion proxy) has no
|
||||||
their own; they resolve whichever config entry's runtime data is
|
database of its own; it resolves whichever config entry's runtime
|
||||||
currently live at call time, so none of them can hold a stale handle
|
data is currently live at call time, so it cannot hold a stale
|
||||||
to a closed connection after another entry unloads. The record also
|
handle to a closed connection after another entry unloads.
|
||||||
carries that entry's validated ``settings`` (e.g. the configured
|
|
||||||
horizon) so service handlers read horizon days off it rather than
|
|
||||||
re-reading HA config.
|
|
||||||
"""
|
"""
|
||||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||||
database = getattr(runtime_data, "database", None)
|
database = getattr(runtime_data, "database", None)
|
||||||
@@ -341,39 +339,17 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _make_regenerate_handler(hass: HomeAssistant):
|
|
||||||
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
|
||||||
|
|
||||||
async def _regenerate(_call: Any) -> None:
|
|
||||||
runtime_data = _find_live_runtime_data(hass)
|
|
||||||
if runtime_data is None:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"NestQuest regenerate requested but no config entry has a "
|
|
||||||
"live database; ignoring"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await _run_horizon_materialization(
|
|
||||||
hass, runtime_data.database, runtime_data.settings
|
|
||||||
)
|
|
||||||
|
|
||||||
return _regenerate
|
|
||||||
|
|
||||||
|
|
||||||
def _register_services(hass: HomeAssistant) -> None:
|
def _register_services(hass: HomeAssistant) -> None:
|
||||||
"""Register domain-global NestQuest services once.
|
"""Register the domain-global NestQuest service once.
|
||||||
|
|
||||||
Services are DOMAIN-global — registered exactly once (guarded by
|
The service is DOMAIN-global — registered exactly once (guarded by
|
||||||
``has_service`` on regenerate) and NOT bound to any config entry —
|
``has_service``) and NOT bound to any config entry — so the panel
|
||||||
so each handler resolves the currently-live database at call time
|
completion proxy resolves the currently-live runtime at call time
|
||||||
and a name collision or duplicate setup is impossible. Removal is
|
and a name collision or duplicate setup is impossible. Removal is
|
||||||
handled by the unload path only once the LAST entry is removed
|
handled by the unload path only once the LAST entry is removed
|
||||||
(see :func:`_deregister_services`).
|
(see :func:`_deregister_services`).
|
||||||
"""
|
"""
|
||||||
async_register_services(
|
async_register_services(hass, find_runtime=_find_live_runtime_data)
|
||||||
hass,
|
|
||||||
find_runtime=_find_live_runtime_data,
|
|
||||||
regenerate_handler=_make_regenerate_handler(hass),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _deregister_services(hass: HomeAssistant) -> None:
|
def _deregister_services(hass: HomeAssistant) -> None:
|
||||||
@@ -483,8 +459,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
# Backfill any days the daily listener missed while the integration
|
# Backfill any days the daily listener missed while the integration
|
||||||
# was off (or freshly installed): run the one idempotent walk over
|
# was off (or freshly installed): run the one idempotent walk over
|
||||||
# [today, today + horizon_days] once at setup, using the entry's
|
# [today, today + horizon_days] once at setup, using the entry's
|
||||||
# configured horizon. This runs through the SAME path as the daily
|
# configured horizon. This runs through the SAME path as the
|
||||||
# listener and regenerate service.
|
# daily listener.
|
||||||
await _run_horizon_materialization(hass, database, settings)
|
await _run_horizon_materialization(hass, database, settings)
|
||||||
# The missed sweep runs at startup too (Feature 11): if HA was
|
# The missed sweep runs at startup too (Feature 11): if HA was
|
||||||
# down at the rollover time, the previous night's still-open
|
# down at the rollover time, the previous night's still-open
|
||||||
|
|||||||
@@ -20,40 +20,25 @@ TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
|||||||
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||||
|
|
||||||
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
|
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
|
||||||
# panel and admin cards listen for these; Feature 10 fires completed,
|
# panel and admin cards listen for these; since Feature 18 the
|
||||||
# uncompleted, and child_day_complete from the service paths, and
|
# completed/uncompleted/day-complete transitions are published by the
|
||||||
# Feature 11's nightly sweep fires quest_missed.
|
# API service on its SSE stream and re-fired on the bus by the
|
||||||
|
# integration's subscription, and Feature 11's nightly sweep fires
|
||||||
|
# quest_missed.
|
||||||
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
|
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
|
||||||
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
|
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
|
||||||
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||||
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
|
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
|
||||||
|
|
||||||
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2).
|
||||||
#: plus the existing Feature 07 regenerate service).
|
#: Since Feature 18 only the panel completion service remains: the admin
|
||||||
|
#: surface is the PWA and the API service's admin routes, so the former
|
||||||
|
#: HA admin services (uncomplete, quest-definition and presence
|
||||||
|
#: management, CSV export, child management, regenerate) are gone.
|
||||||
SERVICE_COMPLETE_QUEST = "complete_quest"
|
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"
|
|
||||||
|
|
||||||
DOMAIN_SERVICES: tuple[str, ...] = (
|
DOMAIN_SERVICES: tuple[str, ...] = (
|
||||||
SERVICE_COMPLETE_QUEST,
|
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"
|
||||||
|
|||||||
@@ -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)}"
|
|
||||||
)
|
|
||||||
@@ -4,8 +4,10 @@ The transition-event payload construction lives in
|
|||||||
:mod:`custom_components.nestquest.core.events` (extracted in
|
:mod:`custom_components.nestquest.core.events` (extracted in
|
||||||
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
|
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
|
||||||
shim keeps the integration's old ``fire_quest_completed`` /
|
shim keeps the integration's old ``fire_quest_completed`` /
|
||||||
``fire_quest_uncompleted`` API (the complete/uncomplete service
|
``fire_quest_uncompleted`` API for direct callers — since Feature 18
|
||||||
handlers call them with ``hass``), reads the HA-local timezone from
|
the HA service surface no longer fires transition events itself (the
|
||||||
|
API service publishes them on its SSE stream and the integration's
|
||||||
|
subscription re-fires them) — reads the HA-local timezone from
|
||||||
``hass.config.time_zone`` to derive ``today``, asks the core builder
|
``hass.config.time_zone`` to derive ``today``, asks the core builder
|
||||||
for the events, and fires each on ``hass.bus`` with the documented
|
for the events, and fires each on ``hass.bus`` with the documented
|
||||||
payloads. The four blueprints keep working with identical payloads.
|
payloads. The four blueprints keep working with identical payloads.
|
||||||
|
|||||||
@@ -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,11 +0,0 @@
|
|||||||
"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package.
|
|
||||||
|
|
||||||
The service policy registry lives in
|
|
||||||
:mod:`custom_components.nestquest.core.service_policy` (extracted in
|
|
||||||
task 510c1f78); this module re-exports it so existing
|
|
||||||
``from .service_policy import ...`` references inside the integration
|
|
||||||
keep working.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .core.service_policy import * # noqa: F401,F403
|
|
||||||
@@ -1,65 +1,36 @@
|
|||||||
"""Domain-global NestQuest service schemas and handlers (Feature 09).
|
"""The domain-global ``nestquest.complete_quest`` service (Feature 18).
|
||||||
|
|
||||||
Registration is domain-scoped and happens once (see
|
Registration is domain-scoped and happens once (see
|
||||||
:func:`async_register_services`); handlers resolve a live database at
|
:func:`async_register_services`). Since Feature 18 the integration is
|
||||||
call time through the injected ``find_runtime`` callable. Permission
|
a client of the NestQuest API service, so ``complete_quest`` proxies
|
||||||
checks are Feature 09 later tasks — this module only validates payload
|
to the API's panel complete route through the entry's shared API
|
||||||
shape and forwards to the Feature 03/06/08 business layers (the
|
client instead of writing any database. The former HA admin services
|
||||||
presence writes included, through :mod:`.presence_management` — the
|
(uncomplete, quest-definition and presence management, CSV export,
|
||||||
ONE implementation the API admin plane shares). Since Feature 18 the
|
child management, regenerate) are removed: the admin surface is the
|
||||||
integration is a client of the NestQuest API service, so
|
PWA and the API service's admin routes, so the only HA service left is
|
||||||
``complete_quest`` proxies to the API's panel complete route through
|
the panel completion path.
|
||||||
the entry's shared API client instead of writing any database.
|
|
||||||
``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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from . import children as children_layer
|
|
||||||
from . import completion as completion_layer
|
|
||||||
from . import presence_management as presence_management_layer
|
|
||||||
from . import quest_definitions as quest_definitions_layer
|
|
||||||
from .api_client import NestQuestApiError
|
from .api_client import NestQuestApiError
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
DOMAIN_SERVICES,
|
DOMAIN_SERVICES,
|
||||||
QUEST_WINDOWS,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
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 .events import fire_quest_uncompleted
|
|
||||||
from .permissions import permission_gate
|
|
||||||
from .recurrence import ScheduleRule
|
|
||||||
from .service_policy import SERVICE_POLICY
|
|
||||||
|
|
||||||
FindRuntime = Callable[[HomeAssistant], Any]
|
FindRuntime = Callable[[HomeAssistant], Any]
|
||||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||||
|
|
||||||
ACTOR_PANEL = "panel"
|
ACTOR_PANEL = "panel"
|
||||||
ACTOR_USER = "user"
|
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:
|
def _strict_int(value: object) -> int:
|
||||||
@@ -69,82 +40,6 @@ def _strict_int(value: object) -> int:
|
|||||||
return value
|
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(
|
SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("instance_id"): _strict_int,
|
vol.Required("instance_id"): _strict_int,
|
||||||
@@ -154,86 +49,6 @@ SCHEMA_COMPLETE_QUEST = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
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):
|
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
||||||
runtime = find_runtime(hass)
|
runtime = find_runtime(hass)
|
||||||
@@ -245,11 +60,11 @@ def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
|||||||
|
|
||||||
|
|
||||||
async def _refresh_entities(runtime: Any) -> None:
|
async def _refresh_entities(runtime: Any) -> None:
|
||||||
"""Force an immediate coordinator refresh after a service mutation.
|
"""Force an immediate coordinator refresh after the service call.
|
||||||
|
|
||||||
Only the SERVICE paths call this — the scheduled materialize and
|
Only the SERVICE path calls this — the scheduled materialize and
|
||||||
rollover internals are direct function calls and never widen this
|
rollover internals are direct function calls and never widen this
|
||||||
gate — so entities reflect a completion the moment the service
|
gate — so the entities reflect a completion the moment the service
|
||||||
call returns instead of waiting for the poll interval.
|
call returns instead of waiting for the poll interval.
|
||||||
"""
|
"""
|
||||||
coordinator = getattr(runtime, "coordinator", None)
|
coordinator = getattr(runtime, "coordinator", None)
|
||||||
@@ -257,12 +72,6 @@ async def _refresh_entities(runtime: Any) -> None:
|
|||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
|
|
||||||
|
|
||||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
|
||||||
time_zone = ZoneInfo(hass.config.time_zone)
|
|
||||||
today = datetime.datetime.now(time_zone).date()
|
|
||||||
return {"today": today, "horizon_days": runtime.settings.horizon_days}
|
|
||||||
|
|
||||||
|
|
||||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||||
actor = call.data["actor"]
|
actor = call.data["actor"]
|
||||||
if actor == ACTOR_PANEL:
|
if actor == ACTOR_PANEL:
|
||||||
@@ -353,285 +162,25 @@ def _complete_quest(
|
|||||||
return _with_errors(_handler)
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|
||||||
def _uncomplete_quest(
|
|
||||||
hass: HomeAssistant, find_runtime: FindRuntime
|
|
||||||
) -> ServiceHandler:
|
|
||||||
async def _handler(call: Any) -> None:
|
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
|
||||||
instance_id = call.data["instance_id"]
|
|
||||||
result = await completion_layer.uncomplete_instance(
|
|
||||||
runtime.database,
|
|
||||||
instance_id,
|
|
||||||
**_actor_kwargs(call),
|
|
||||||
)
|
|
||||||
if result.appended:
|
|
||||||
await fire_quest_uncompleted(
|
|
||||||
hass, runtime.database, instance_id
|
|
||||||
)
|
|
||||||
await _refresh_entities(runtime)
|
|
||||||
|
|
||||||
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
|
|
||||||
await presence_management_layer.set_presence_schedule(
|
|
||||||
runtime.database,
|
|
||||||
data["child_id"],
|
|
||||||
data["cycle_length_weeks"],
|
|
||||||
data["anchor_date"],
|
|
||||||
data["pattern"],
|
|
||||||
**_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 presence_management_layer.create_presence_override(
|
|
||||||
runtime.database,
|
|
||||||
data["child_id"],
|
|
||||||
data["start_date"],
|
|
||||||
data["end_date"],
|
|
||||||
data["is_present"],
|
|
||||||
note=data.get("note"),
|
|
||||||
**_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)
|
|
||||||
await presence_management_layer.delete_presence_override(
|
|
||||||
runtime.database,
|
|
||||||
call.data["override_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(
|
def async_register_services(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
*,
|
*,
|
||||||
find_runtime: FindRuntime,
|
find_runtime: FindRuntime,
|
||||||
regenerate_handler: ServiceHandler,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register every canonical service once at domain scope.
|
"""Register the panel completion service once at domain scope.
|
||||||
|
|
||||||
Every service — regenerate included — passes through the Feature
|
The one remaining HA service is the panel completion path; every
|
||||||
09 permission gate before its handler runs; the gate reads
|
admin operation moved to the API service's admin routes, so there
|
||||||
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
|
is no permission gate to apply here.
|
||||||
registered exempt from the policy.
|
|
||||||
"""
|
"""
|
||||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
if hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST):
|
||||||
return
|
return
|
||||||
gate = permission_gate
|
hass.services.async_register(
|
||||||
specs: list[tuple[str, ServiceHandler, object]] = [
|
DOMAIN,
|
||||||
(SERVICE_REGENERATE, regenerate_handler, None),
|
SERVICE_COMPLETE_QUEST,
|
||||||
(
|
_complete_quest(hass, find_runtime),
|
||||||
SERVICE_COMPLETE_QUEST,
|
schema=SCHEMA_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:
|
def async_deregister_services(hass: HomeAssistant) -> None:
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
# Service declarations for the NestQuest integration (Home Assistant
|
# Service declarations for the NestQuest integration (Home Assistant
|
||||||
# services.yaml metadata). Services are domain-scoped: nested under the
|
# services.yaml metadata). Services are domain-scoped: nested under the
|
||||||
# "nestquest" domain, not repeated per config entry.
|
# "nestquest" domain, not repeated per config entry. Since Feature 18
|
||||||
|
# only the panel completion service remains — every admin operation
|
||||||
regenerate:
|
# moved to the PWA and the API service's admin routes.
|
||||||
name: Regenerate quest instances
|
|
||||||
description: >-
|
|
||||||
Regenerate every future quest instance for the household over the rolling
|
|
||||||
horizon, using the same idempotent materialization as the daily rollover
|
|
||||||
and the startup backfill. Safe to call at any time: a re-run refreshes the
|
|
||||||
same rows instead of duplicating them, and completed instances are never
|
|
||||||
touched.
|
|
||||||
|
|
||||||
complete_quest:
|
complete_quest:
|
||||||
name: Complete quest
|
name: Complete quest
|
||||||
@@ -44,283 +37,3 @@ complete_quest:
|
|||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
text:
|
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:
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from custom_components.nestquest.children import (
|
|||||||
list_children,
|
list_children,
|
||||||
set_child_active,
|
set_child_active,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.completion import derive_state
|
from custom_components.nestquest.completion import derive_state, uncomplete_instance
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -30,7 +30,9 @@ from custom_components.nestquest.const import (
|
|||||||
EVENT_QUEST_COMPLETED,
|
EVENT_QUEST_COMPLETED,
|
||||||
EVENT_QUEST_UNCOMPLETED,
|
EVENT_QUEST_UNCOMPLETED,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
)
|
||||||
|
from custom_components.nestquest.core.events import (
|
||||||
|
build_quest_uncompleted_events,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
from custom_components.nestquest.materialize import materialize
|
from custom_components.nestquest.materialize import materialize
|
||||||
@@ -174,14 +176,29 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Reversing one quest fires the uncompleted event exactly once
|
# --- Reversing one quest fires the uncompleted event exactly once
|
||||||
# and restores the owed counts.
|
# and restores the owed counts. The HA ``uncomplete_quest``
|
||||||
await hass.services.call(
|
# service is gone (the API service's admin route is the reversal
|
||||||
DOMAIN,
|
# path): drive the core completion layer it calls, publish the
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
# uncompleted transition the way the API does, and deliver it
|
||||||
{"instance_id": instances[0].id, "actor": "user"},
|
# through the SSE subscription.
|
||||||
context={"user_id": "admin-1"},
|
reversal = await uncomplete_instance(
|
||||||
|
entry.runtime_data.database,
|
||||||
|
instances[0].id,
|
||||||
|
actor_source="user",
|
||||||
|
actor_user_id="admin-1",
|
||||||
)
|
)
|
||||||
|
assert reversal.appended is True
|
||||||
|
client = coordinator.api_client
|
||||||
|
client.published_frames.extend(
|
||||||
|
await build_quest_uncompleted_events(
|
||||||
|
entry.runtime_data.database, instances[0].id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
||||||
|
# The reversal no longer carries an immediate local refresh: the
|
||||||
|
# coordinator's next poll picks the restored counts up.
|
||||||
|
await coordinator.async_refresh()
|
||||||
assert _sensor(hass, ada.id, "all_done").is_on is False
|
assert _sensor(hass, ada.id, "all_done").is_on is False
|
||||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
|
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
|
||||||
|
|
||||||
|
|||||||
+33
-60
@@ -9,7 +9,11 @@ re-fires them. The service-driven tests below therefore deliver the
|
|||||||
frames through :func:`conftest.refire_api_transitions` (the
|
frames through :func:`conftest.refire_api_transitions` (the
|
||||||
production event path, driven synchronously) before asserting the
|
production event path, driven synchronously) before asserting the
|
||||||
bus; the shim-level tests drive :mod:`custom_components.nestquest
|
bus; the shim-level tests drive :mod:`custom_components.nestquest
|
||||||
.events` directly, the way its remaining callers do.
|
.events` directly, the way its remaining callers do. The HA
|
||||||
|
``uncomplete_quest`` service is also gone (the API service's admin
|
||||||
|
uncomplete route is the reversal path), so the reversal tests drive
|
||||||
|
the core completion layer the route calls and deliver the published
|
||||||
|
frame through the same SSE subscription helper.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,6 +23,7 @@ from conftest import refire_api_transitions, wire_entry_to_registry
|
|||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
from custom_components.nestquest import async_setup_entry
|
||||||
from custom_components.nestquest.children import create_child
|
from custom_components.nestquest.children import create_child
|
||||||
|
from custom_components.nestquest.completion import uncomplete_instance
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
CONF_ADMIN_USER_IDS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -27,10 +32,9 @@ from custom_components.nestquest.const import (
|
|||||||
EVENT_QUEST_MISSED,
|
EVENT_QUEST_MISSED,
|
||||||
EVENT_QUEST_UNCOMPLETED,
|
EVENT_QUEST_UNCOMPLETED,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
)
|
||||||
SERVICE_MANAGE_CHILD,
|
from custom_components.nestquest.core.events import (
|
||||||
SERVICE_REGENERATE,
|
build_quest_uncompleted_events,
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||||
from custom_components.nestquest.materialize import materialize
|
from custom_components.nestquest.materialize import materialize
|
||||||
@@ -39,7 +43,6 @@ from custom_components.nestquest.quest_definitions import (
|
|||||||
)
|
)
|
||||||
from custom_components.nestquest.recurrence import ScheduleRule
|
from custom_components.nestquest.recurrence import ScheduleRule
|
||||||
|
|
||||||
ADMIN_CTX = {"user_id": "admin-1"}
|
|
||||||
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
||||||
|
|
||||||
|
|
||||||
@@ -61,15 +64,7 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",), seed_date=N
|
|||||||
)
|
)
|
||||||
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
|
||||||
await hass.services.call(
|
child = await create_child(database, "Ada")
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
from custom_components.nestquest.children import list_children
|
|
||||||
|
|
||||||
child = (await list_children(database))[0]
|
|
||||||
await create_quest_definition(
|
await create_quest_definition(
|
||||||
database,
|
database,
|
||||||
"Brush teeth",
|
"Brush teeth",
|
||||||
@@ -205,32 +200,44 @@ async def test_child_day_complete_does_not_fire_partway(
|
|||||||
|
|
||||||
|
|
||||||
async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None:
|
async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None:
|
||||||
|
"""An actual reversal fires the uncompleted event — but only once,
|
||||||
|
and only through the API service's transition stream: the HA
|
||||||
|
``uncomplete_quest`` service is gone, so the reversal is driven
|
||||||
|
through the core completion layer the API's admin uncomplete route
|
||||||
|
calls, with the published frame delivered by the SSE subscription."""
|
||||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||||
instance_id = instances[0].id
|
instance_id = instances[0].id
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
client = entry.runtime_data.coordinator.api_client
|
||||||
await hass.services.call(
|
await hass.services.call(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||||
)
|
)
|
||||||
# Un-completing an open instance is a no-op and fires nothing.
|
# The reversal appends through the core completion layer; the API
|
||||||
await hass.services.call(
|
# route publishes the transition on its SSE stream and the
|
||||||
DOMAIN,
|
# subscription re-fires it on the bus.
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
result = await uncomplete_instance(
|
||||||
{"instance_id": instance_id, "actor": "user"},
|
database, instance_id, actor_source="user", actor_user_id="admin-1"
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
)
|
||||||
|
assert result.appended is True
|
||||||
|
client.published_frames.extend(
|
||||||
|
await build_quest_uncompleted_events(database, instance_id)
|
||||||
|
)
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED)
|
fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED)
|
||||||
assert len(fired) == 1
|
assert len(fired) == 1
|
||||||
payload = fired[0]
|
payload = fired[0]
|
||||||
assert payload["instance_id"] == instance_id
|
assert payload["instance_id"] == instance_id
|
||||||
assert payload["quest_title"] == "Brush teeth"
|
assert payload["quest_title"] == "Brush teeth"
|
||||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||||
await hass.services.call(
|
# Un-completing an already-open instance is a no-op: nothing is
|
||||||
DOMAIN,
|
# appended, nothing is published, and nothing further fires.
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
no_op = await uncomplete_instance(
|
||||||
{"instance_id": instance_id, "actor": "user"},
|
database, instance_id, actor_source="user", actor_user_id="admin-1"
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
)
|
||||||
|
assert no_op.appended is False
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -390,40 +397,6 @@ async def test_completion_updates_entities_immediately(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_uncompletion_updates_entities_immediately(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
|
||||||
coordinator = entry.runtime_data.coordinator
|
|
||||||
await coordinator.async_refresh()
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
{
|
|
||||||
"instance_id": instances[0].id,
|
|
||||||
"actor": "panel",
|
|
||||||
"actor_child_id": child.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
completed_after_complete = hass.entities[
|
|
||||||
f"nestquest_child_{child.id}_quests_completed_today"
|
|
||||||
].native_value
|
|
||||||
assert completed_after_complete == 1
|
|
||||||
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
{"instance_id": instances[0].id, "actor": "user"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
completed_after_uncomplete = hass.entities[
|
|
||||||
f"nestquest_child_{child.id}_quests_completed_today"
|
|
||||||
].native_value
|
|
||||||
assert completed_after_uncomplete == 0, (
|
|
||||||
"sensor must reflect the reversal without a manual refresh"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_forced_refreshes_serialize_no_stale_publish(
|
async def test_forced_refreshes_serialize_no_stale_publish(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
+2
-131
@@ -9,22 +9,14 @@ 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,
|
||||||
CONF_MORNING_SUMMARY_TIME,
|
CONF_MORNING_SUMMARY_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
SERVICE_REGENERATE,
|
SERVICE_COMPLETE_QUEST,
|
||||||
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)
|
||||||
@@ -601,127 +593,6 @@ async def test_setup_keeps_horizon_when_sibling_option_is_bad(
|
|||||||
assert entry.runtime_data.settings.horizon_days == 5
|
assert entry.runtime_data.settings.horizon_days == 5
|
||||||
|
|
||||||
|
|
||||||
async def test_regenerate_service_registered_and_materializes(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
"""``nestquest.regenerate`` is registered and calling it regenerates."""
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
|
|
||||||
entry = _wire(make_entry(data=_admin_data()), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
# A definition created AFTER setup has no instances yet (create does not
|
|
||||||
# materialize); the service must generate them on demand.
|
|
||||||
database = entry.runtime_data.database
|
|
||||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
|
||||||
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
|
|
||||||
today = _local_today(hass)
|
|
||||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
|
||||||
records = await QuestInstancesDao(database).list_by_date_range(
|
|
||||||
child_id, today.isoformat(), end
|
|
||||||
)
|
|
||||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_regenerate_service_uses_configured_horizon_days(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
"""``nestquest.regenerate`` regenerates only the configured horizon."""
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
|
|
||||||
entry = _wire(
|
|
||||||
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
|
|
||||||
hass.registry,
|
|
||||||
)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
|
|
||||||
database = entry.runtime_data.database
|
|
||||||
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
|
|
||||||
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
|
|
||||||
today = _local_today(hass)
|
|
||||||
end = (today + datetime.timedelta(days=5)).isoformat()
|
|
||||||
records = await QuestInstancesDao(database).list_by_date_range(
|
|
||||||
child_id, today.isoformat(), end
|
|
||||||
)
|
|
||||||
assert len(records) == 6
|
|
||||||
|
|
||||||
beyond = (today + datetime.timedelta(days=6)).isoformat()
|
|
||||||
beyond_end = (
|
|
||||||
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
||||||
).isoformat()
|
|
||||||
assert await QuestInstancesDao(database).list_by_date_range(
|
|
||||||
child_id, beyond, beyond_end
|
|
||||||
) == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
|
|
||||||
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
|
|
||||||
entry = _wire(make_entry(), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
assert await async_unload_entry(hass, entry) is True
|
|
||||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_regenerate_service_is_domain_scoped_across_entries(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
"""The ``regenerate`` service is domain-global, not per-entry.
|
|
||||||
|
|
||||||
With two loaded entries the service must be registered and functional;
|
|
||||||
unloading one entry must keep it registered and functional for the
|
|
||||||
remaining entry; unloading the final entry must remove it.
|
|
||||||
"""
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
|
|
||||||
entry_a = _wire(
|
|
||||||
make_entry(entry_id="entry_a", data=_admin_data()), hass.registry
|
|
||||||
)
|
|
||||||
entry_b = _wire(
|
|
||||||
make_entry(entry_id="entry_b", data=_admin_data()), hass.registry
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await async_setup_entry(hass, entry_a) is True
|
|
||||||
assert await async_setup_entry(hass, entry_b) is True
|
|
||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
# The domain-global handler resolves a live database from any entry.
|
|
||||||
database_b = entry_b.runtime_data.database
|
|
||||||
child_id = await _seed_daily_child_and_definition(
|
|
||||||
database_b, _local_today(hass)
|
|
||||||
)
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
today = _local_today(hass)
|
|
||||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
|
||||||
records = await QuestInstancesDao(database_b).list_by_date_range(
|
|
||||||
child_id, today.isoformat(), end
|
|
||||||
)
|
|
||||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
|
||||||
|
|
||||||
# Unloading one entry must not tear down the shared service.
|
|
||||||
assert await async_unload_entry(hass, entry_a) is True
|
|
||||||
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
# It still works for the remaining entry.
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
|
|
||||||
# Unloading the final entry removes the domain-global service.
|
|
||||||
assert await async_unload_entry(hass, entry_b) is True
|
|
||||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_setup_cleans_listeners_when_materialization_raises(
|
async def test_setup_cleans_listeners_when_materialization_raises(
|
||||||
hass, make_entry, monkeypatch
|
hass, make_entry, monkeypatch
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -745,5 +616,5 @@ async def test_setup_cleans_listeners_when_materialization_raises(
|
|||||||
|
|
||||||
assert hass.time_change.size == 0
|
assert hass.time_change.size == 0
|
||||||
assert hass.registry.size == 0
|
assert hass.registry.size == 0
|
||||||
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
|
assert not hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST)
|
||||||
assert not hasattr(entry, "runtime_data")
|
assert not hasattr(entry, "runtime_data")
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
"""Feature 09 permission test matrix across every registered service."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from homeassistant.exceptions import Unauthorized
|
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
|
||||||
from custom_components.nestquest.const import (
|
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
DEFAULT_HORIZON_DAYS,
|
|
||||||
DOMAIN,
|
|
||||||
DOMAIN_SERVICES,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
|
||||||
SERVICE_EXPORT_HISTORY_CSV,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
SERVICE_REGENERATE,
|
|
||||||
SERVICE_SET_PRESENCE_PATTERN,
|
|
||||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
|
||||||
)
|
|
||||||
from custom_components.nestquest.service_policy import (
|
|
||||||
ADMIN_ONLY,
|
|
||||||
OPEN,
|
|
||||||
SERVICE_POLICY,
|
|
||||||
)
|
|
||||||
|
|
||||||
ADMIN_ID = "admin-1"
|
|
||||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
|
||||||
NON_ADMIN_ID = "non-admin"
|
|
||||||
NON_ADMIN_CTX = {"user_id": NON_ADMIN_ID}
|
|
||||||
KIOSK_ID = "kiosk-panel"
|
|
||||||
KIOSK_CTX = {"user_id": KIOSK_ID}
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_registered_service_is_in_the_operation_registry() -> None:
|
|
||||||
"""The registry is total: a service outside it could never be gated."""
|
|
||||||
missing = [
|
|
||||||
service for service in DOMAIN_SERVICES if service not in SERVICE_POLICY
|
|
||||||
]
|
|
||||||
extra = [
|
|
||||||
service for service in SERVICE_POLICY if service not in DOMAIN_SERVICES
|
|
||||||
]
|
|
||||||
assert not missing, f"registered but ungated: {missing}"
|
|
||||||
assert not extra, f"gated but never registered: {extra}"
|
|
||||||
assert set(SERVICE_POLICY.values()) == {OPEN, ADMIN_ONLY}
|
|
||||||
|
|
||||||
|
|
||||||
async def _setup(hass, make_entry, *, admin=True):
|
|
||||||
data = {CONF_ADMIN_USER_IDS: [ADMIN_ID]} if admin else {}
|
|
||||||
entry = wire_entry_to_registry(make_entry(data=data), hass.registry)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
async def _seed(hass, entry) -> dict:
|
|
||||||
"""Seed one child, one definition, and materialized instances."""
|
|
||||||
from custom_components.nestquest.children import list_children
|
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
||||||
|
|
||||||
database = entry.runtime_data.database
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_CREATE_QUEST_DEFINITION,
|
|
||||||
{
|
|
||||||
"title": "Brush teeth",
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"assignee_child_ids": [(await list_children(database))[0].id],
|
|
||||||
"windows": ["morning"],
|
|
||||||
},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
|
|
||||||
child = (await list_children(database))[0]
|
|
||||||
today = datetime.date.today().isoformat()
|
|
||||||
end = (
|
|
||||||
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
|
||||||
).isoformat()
|
|
||||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
|
||||||
child.id, today, end
|
|
||||||
)
|
|
||||||
assert instances
|
|
||||||
from custom_components.nestquest.dao_presence import (
|
|
||||||
PresenceOverridesDao,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"child_id": child.id,
|
|
||||||
"instance_id": instances[0].id,
|
|
||||||
"definition_id": instances[0].definition_id,
|
|
||||||
"override": await PresenceOverridesDao(database).create(
|
|
||||||
child.id, "2027-01-01", "2027-01-02", False
|
|
||||||
),
|
|
||||||
"today": today,
|
|
||||||
"database": database,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_success_payload(service: str, ctx: dict) -> dict | None:
|
|
||||||
"""A schema-valid, end-to-end-successful payload per admin service."""
|
|
||||||
if service == SERVICE_UNCOMPLETE_QUEST:
|
|
||||||
return {"instance_id": ctx["instance_id"], "actor": "user"}
|
|
||||||
if service == SERVICE_CREATE_QUEST_DEFINITION:
|
|
||||||
return {
|
|
||||||
"title": "Second quest",
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"assignee_child_ids": [ctx["child_id"]],
|
|
||||||
"windows": ["afternoon"],
|
|
||||||
}
|
|
||||||
if service == SERVICE_UPDATE_QUEST_DEFINITION:
|
|
||||||
return {"definition_id": ctx["definition_id"], "title": "Brush teeth 2"}
|
|
||||||
if service == SERVICE_SET_QUEST_DEFINITION_ACTIVE:
|
|
||||||
return {"definition_id": ctx["definition_id"], "is_active": False}
|
|
||||||
if service == SERVICE_SET_PRESENCE_PATTERN:
|
|
||||||
return {
|
|
||||||
"child_id": ctx["child_id"],
|
|
||||||
"cycle_length_weeks": 1,
|
|
||||||
"anchor_date": ctx["today"],
|
|
||||||
"pattern": {0: [0, 1, 2, 3, 4, 5, 6]},
|
|
||||||
}
|
|
||||||
if service == SERVICE_CREATE_PRESENCE_OVERRIDE:
|
|
||||||
return {
|
|
||||||
"child_id": ctx["child_id"],
|
|
||||||
"start_date": "2027-03-01",
|
|
||||||
"end_date": "2027-03-02",
|
|
||||||
"is_present": False,
|
|
||||||
}
|
|
||||||
if service == SERVICE_DELETE_PRESENCE_OVERRIDE:
|
|
||||||
return {"override_id": ctx["override"].id}
|
|
||||||
if service == SERVICE_EXPORT_HISTORY_CSV:
|
|
||||||
return None # raises HomeAssistantError (Feature 13) — not Unauthorized
|
|
||||||
if service == SERVICE_MANAGE_CHILD:
|
|
||||||
return {"action": "create", "display_name": "Bo"}
|
|
||||||
if service == SERVICE_REGENERATE:
|
|
||||||
return {}
|
|
||||||
raise AssertionError(f"unexpected service {service}")
|
|
||||||
|
|
||||||
|
|
||||||
_ADMIN_ONLY = [
|
|
||||||
service for service, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("service", _ADMIN_ONLY)
|
|
||||||
async def test_admin_succeeds_on_every_admin_only_service(
|
|
||||||
hass, make_entry, service
|
|
||||||
) -> None:
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
|
|
||||||
entry = await _setup(hass, make_entry)
|
|
||||||
ctx = await _seed(hass, entry)
|
|
||||||
if service == SERVICE_EXPORT_HISTORY_CSV:
|
|
||||||
# The gate passes; the Feature 13 stub raises its "not
|
|
||||||
# implemented" HomeAssistantError — NOT Unauthorized.
|
|
||||||
with pytest.raises(HomeAssistantError, match="Feature 13"):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN, service, None, context=ADMIN_CTX
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN, service, _admin_success_payload(service, ctx), context=ADMIN_CTX
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("service", _ADMIN_ONLY)
|
|
||||||
async def test_non_admin_refused_on_every_admin_only_service(
|
|
||||||
hass, make_entry, service
|
|
||||||
) -> None:
|
|
||||||
entry = await _setup(hass, make_entry)
|
|
||||||
ctx = await _seed(hass, entry)
|
|
||||||
if service != SERVICE_EXPORT_HISTORY_CSV:
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
service,
|
|
||||||
_admin_success_payload(service, ctx),
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
service,
|
|
||||||
_admin_success_payload(service, ctx),
|
|
||||||
context=NON_ADMIN_CTX,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_non_admin_allowed_on_complete_quest(hass, make_entry) -> None:
|
|
||||||
from custom_components.nestquest.completion import instance_state
|
|
||||||
|
|
||||||
entry = await _setup(hass, make_entry)
|
|
||||||
ctx = await _seed(hass, entry)
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
{
|
|
||||||
"instance_id": ctx["instance_id"],
|
|
||||||
"actor": "panel",
|
|
||||||
"actor_child_id": ctx["child_id"],
|
|
||||||
},
|
|
||||||
context=NON_ADMIN_CTX,
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
await instance_state(ctx["database"], ctx["instance_id"]) == "done"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_kiosk_refused_on_uncomplete_and_regenerate(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
entry = await _setup(hass, make_entry)
|
|
||||||
ctx = await _seed(hass, entry)
|
|
||||||
from custom_components.nestquest.admin_allowlist import list_admin_ids
|
|
||||||
|
|
||||||
assert KIOSK_ID not in await list_admin_ids(ctx["database"])
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_UNCOMPLETE_QUEST,
|
|
||||||
{"instance_id": ctx["instance_id"], "actor": "user"},
|
|
||||||
context=KIOSK_CTX,
|
|
||||||
)
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN, SERVICE_REGENERATE, context=KIOSK_CTX
|
|
||||||
)
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"""Tests for the Feature 09 permission gate's audit logging."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from homeassistant.exceptions import Unauthorized
|
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
|
||||||
from custom_components.nestquest.const import (
|
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
)
|
|
||||||
|
|
||||||
ADMIN_ID = "admin-1"
|
|
||||||
ADMIN_CTX = {"user_id": ADMIN_ID}
|
|
||||||
_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _set_up(hass, make_entry):
|
|
||||||
entry = wire_entry_to_registry(
|
|
||||||
make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}), hass.registry
|
|
||||||
)
|
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
async def test_denied_call_logs_warning_with_fields(
|
|
||||||
hass, make_entry, caplog
|
|
||||||
) -> None:
|
|
||||||
entry = await _set_up(hass, make_entry)
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context={"user_id": "kiosk-panel"},
|
|
||||||
)
|
|
||||||
denials = [
|
|
||||||
record
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelno == logging.WARNING
|
|
||||||
and "denied" in record.getMessage()
|
|
||||||
]
|
|
||||||
assert len(denials) == 1
|
|
||||||
message = denials[0].getMessage()
|
|
||||||
assert SERVICE_MANAGE_CHILD in message
|
|
||||||
assert "kiosk-panel" in message
|
|
||||||
assert _TIMESTAMP.search(message), message
|
|
||||||
|
|
||||||
|
|
||||||
async def test_denied_no_context_logs_user_none(
|
|
||||||
hass, make_entry, caplog
|
|
||||||
) -> None:
|
|
||||||
entry = await _set_up(hass, make_entry)
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
)
|
|
||||||
denials = [
|
|
||||||
record
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelno == logging.WARNING
|
|
||||||
and "denied" in record.getMessage()
|
|
||||||
]
|
|
||||||
assert len(denials) == 1
|
|
||||||
message = denials[0].getMessage()
|
|
||||||
assert "none" in message
|
|
||||||
assert _TIMESTAMP.search(message), message
|
|
||||||
|
|
||||||
|
|
||||||
async def test_successful_admin_call_logs_debug_with_fields(
|
|
||||||
hass, make_entry, caplog
|
|
||||||
) -> None:
|
|
||||||
caplog.set_level(logging.DEBUG, logger="custom_components.nestquest")
|
|
||||||
entry = await _set_up(hass, make_entry)
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context=ADMIN_CTX,
|
|
||||||
)
|
|
||||||
allowed = [
|
|
||||||
record
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelno == logging.DEBUG
|
|
||||||
and "allowed" in record.getMessage()
|
|
||||||
]
|
|
||||||
assert len(allowed) == 1
|
|
||||||
message = allowed[0].getMessage()
|
|
||||||
assert SERVICE_MANAGE_CHILD in message
|
|
||||||
assert ADMIN_ID in message
|
|
||||||
assert _TIMESTAMP.search(message), message
|
|
||||||
|
|
||||||
|
|
||||||
async def test_denied_log_carries_no_personal_data_beyond_user_id(
|
|
||||||
hass, make_entry, caplog
|
|
||||||
) -> None:
|
|
||||||
"""The log line names the caller's user id only: the payload's
|
|
||||||
display name must not leak into the audit trail."""
|
|
||||||
entry = await _set_up(hass, make_entry)
|
|
||||||
with pytest.raises(Unauthorized):
|
|
||||||
await hass.services.call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_MANAGE_CHILD,
|
|
||||||
{"action": "create", "display_name": "Ada"},
|
|
||||||
context={"user_id": "kiosk-panel"},
|
|
||||||
)
|
|
||||||
denials = [
|
|
||||||
record.getMessage()
|
|
||||||
for record in caplog.records
|
|
||||||
if record.levelno == logging.WARNING and "denied" in record.getMessage()
|
|
||||||
]
|
|
||||||
assert denials, "expected a denial log line"
|
|
||||||
assert "Ada" not in denials[0]
|
|
||||||
assert "display_name" not in denials[0]
|
|
||||||
@@ -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}
|
|
||||||
+39
-471
@@ -1,4 +1,13 @@
|
|||||||
"""Tests for NestQuest service declarations and domain-global handlers."""
|
"""Tests for NestQuest service declarations and domain-global handlers.
|
||||||
|
|
||||||
|
Since Feature 18 the integration is a client of the NestQuest API
|
||||||
|
service: the only HA service left is the panel completion path
|
||||||
|
(``nestquest.complete_quest``, a proxy to the API's panel complete
|
||||||
|
route). Every admin service was removed — the admin surface is the
|
||||||
|
PWA and the API service's admin routes — so these tests pin the
|
||||||
|
shrunk surface: services.yaml declares nothing else, setup registers
|
||||||
|
nothing else, and no removed admin service name may come back.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -6,27 +15,16 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.exceptions import HomeAssistantError, Unauthorized
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
from conftest import wire_entry_to_registry
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
DOMAIN_SERVICES,
|
DOMAIN_SERVICES,
|
||||||
SERVICE_COMPLETE_QUEST,
|
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
|
||||||
@@ -35,21 +33,27 @@ SERVICES_PATH = (
|
|||||||
/ "services.yaml"
|
/ "services.yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: The admin services removed in Feature 18. None of these names may
|
||||||
|
#: be registered as an HA service again — the admin surface is the PWA
|
||||||
|
#: and the API service's admin routes.
|
||||||
|
REMOVED_ADMIN_SERVICES = (
|
||||||
|
"uncomplete_quest",
|
||||||
|
"create_quest_definition",
|
||||||
|
"update_quest_definition",
|
||||||
|
"set_quest_definition_active",
|
||||||
|
"set_presence_pattern",
|
||||||
|
"create_presence_override",
|
||||||
|
"delete_presence_override",
|
||||||
|
"export_history_csv",
|
||||||
|
"manage_child",
|
||||||
|
"regenerate",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _wire(entry, registry):
|
def _wire(entry, registry):
|
||||||
return wire_entry_to_registry(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:
|
def _service_section(text: str, name: str) -> str:
|
||||||
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
|
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
|
||||||
assert match is not None, f"services.yaml must declare {name!r}"
|
assert match is not None, f"services.yaml must declare {name!r}"
|
||||||
@@ -77,42 +81,33 @@ def test_services_yaml_declares_every_canonical_service() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_services_yaml_declares_regenerate() -> None:
|
def test_services_yaml_no_longer_declares_removed_admin_services() -> None:
|
||||||
|
"""The admin services are gone from the HA service surface."""
|
||||||
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), (
|
for name in REMOVED_ADMIN_SERVICES:
|
||||||
"services.yaml must declare the 'regenerate' service"
|
assert not re.search(rf"^{name}:\s*$", text, re.MULTILINE), (
|
||||||
)
|
f"removed admin service {name!r} must not be declared"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
|
def test_services_yaml_canonical_services_declare_fields() -> None:
|
||||||
"""The regenerate service is documented with a clear description and no
|
|
||||||
required fields (there is no ``fields`` block under it)."""
|
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
|
||||||
section = _service_section(text, "regenerate")
|
|
||||||
assert re.search(r"description:", section), (
|
|
||||||
"regenerate must carry a description"
|
|
||||||
)
|
|
||||||
assert "fields:" not in section, (
|
|
||||||
"regenerate declares no required fields"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_services_yaml_other_services_declare_fields() -> None:
|
|
||||||
text = SERVICES_PATH.read_text(encoding="utf-8")
|
text = SERVICES_PATH.read_text(encoding="utf-8")
|
||||||
for name in DOMAIN_SERVICES:
|
for name in DOMAIN_SERVICES:
|
||||||
if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV):
|
|
||||||
continue
|
|
||||||
section = _service_section(text, name)
|
section = _service_section(text, name)
|
||||||
assert "fields:" in section, f"{name} must declare fields"
|
assert "fields:" in section, f"{name} must declare fields"
|
||||||
|
|
||||||
|
|
||||||
async def test_all_canonical_services_registered_after_setup(
|
async def test_only_complete_quest_registered_after_setup(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Setup registers exactly the panel completion service — and no
|
||||||
|
removed admin service under any name."""
|
||||||
entry = _wire(make_entry(), 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
|
||||||
for service in DOMAIN_SERVICES:
|
for service in DOMAIN_SERVICES:
|
||||||
assert hass.services.has_service(DOMAIN, service), service
|
assert hass.services.has_service(DOMAIN, service), service
|
||||||
|
for service in REMOVED_ADMIN_SERVICES:
|
||||||
|
assert not hass.services.has_service(DOMAIN, service), service
|
||||||
|
|
||||||
|
|
||||||
async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None:
|
async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None:
|
||||||
@@ -138,271 +133,6 @@ async def test_unload_one_of_two_entries_keeps_services(
|
|||||||
assert not hass.services.has_service(DOMAIN, service), service
|
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:
|
async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
|
||||||
entry = _wire(make_entry(), 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
|
||||||
@@ -414,168 +144,6 @@ async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
|
|||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{"instance_id": True, "actor": "panel", "actor_child_id": 1},
|
{"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(
|
async def test_scheduled_internals_never_call_the_service_layer(
|
||||||
|
|||||||
Reference in New Issue
Block a user