Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a15d8077b | ||
|
|
8bee1bbb41 | ||
|
|
6231344520 | ||
|
|
ca26ced59f | ||
|
|
2146531665 | ||
|
|
168bb7b2f1 | ||
|
|
4544f78390 | ||
|
|
9836a6f524 | ||
|
|
d6823a54bc | ||
|
|
fa40e21dc2 | ||
|
|
e60a9892ff | ||
|
|
1432dba74c | ||
|
|
1b6973efd3 | ||
|
|
4606d7ddd0 | ||
|
|
802258d431 | ||
|
|
d9cdac87d7 | ||
|
|
cbd29f5ab7 | ||
|
|
58b11b59cf | ||
|
|
9d21f29eea | ||
|
|
fec607f039 | ||
|
|
a18c0060e4 | ||
|
|
20d7efb04b | ||
|
|
6551a87bd6 | ||
|
|
e9dc0a1445 | ||
|
|
1bb0617471 | ||
|
|
221ab2c20b | ||
|
|
bdf3513609 | ||
|
|
830d663a45 | ||
|
|
bff2f4ff5e | ||
|
|
8f04d93363 | ||
|
|
d9326deeb5 | ||
|
|
94f54523f9 | ||
|
|
51b1388233 | ||
|
|
744a40dcb5 | ||
|
|
e15648232f | ||
|
|
6d48f433b7 | ||
|
|
540f59a353 | ||
|
|
ee994a3938 | ||
|
|
77c4db7b3c | ||
|
|
5885d5e972 | ||
|
|
7ae4018e59 | ||
|
|
cb3c347d61 | ||
|
|
d76cde1af5 | ||
|
|
b3957ecedf | ||
|
|
c2cdbceb09 | ||
|
|
7cde256e2a | ||
|
|
7a1cef1e54 | ||
|
|
913d3658b8 | ||
|
|
0e4f890ef9 | ||
|
|
7ebd893823 | ||
|
|
068ace5693 | ||
|
|
f0fa39660c | ||
|
|
d90c170460 | ||
|
|
0d61ea2c88 | ||
|
|
b7f7e092a0 | ||
|
|
5fe6cb72ab | ||
|
|
7a585afb9e | ||
|
|
ecffa5bb0e | ||
|
|
cfe1609df2 | ||
|
|
5c74034f81 | ||
|
|
1d247fbca1 | ||
|
|
6108755f0c | ||
|
|
d5d972b872 | ||
|
|
09363a57cf | ||
|
|
5578507ce8 | ||
|
|
712527d5ba | ||
|
|
61b77cc2db | ||
|
|
a2ea42673c | ||
|
|
82d1be97fa | ||
|
|
2263481f1e | ||
|
|
522875642d | ||
|
|
9eea769c7e | ||
|
|
b955ab487b | ||
|
|
fe90064b65 |
@@ -2,21 +2,33 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import datetime
|
||||||
import inspect
|
import inspect
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers.event import async_track_time_change
|
||||||
|
|
||||||
from .const import DOMAIN, LOGGER
|
from .const import (
|
||||||
|
CONF_ADMIN_USER_IDS,
|
||||||
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
|
DEFAULT_HORIZON_DAYS,
|
||||||
|
DOMAIN,
|
||||||
|
LOGGER,
|
||||||
|
)
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
|
from .materialize import materialize as _materialize_run
|
||||||
from .migrations import apply_migrations
|
from .migrations import apply_migrations
|
||||||
from .store import async_get_db_path
|
from .store import async_get_db_path
|
||||||
|
from .admin_allowlist import seed_setup_admin
|
||||||
|
|
||||||
_LOGGER = LOGGER
|
_LOGGER = LOGGER
|
||||||
|
|
||||||
@@ -223,6 +235,71 @@ class NestQuestRuntimeData:
|
|||||||
options: dict[str, Any]
|
options: dict[str, Any]
|
||||||
database: NestQuestDatabase
|
database: NestQuestDatabase
|
||||||
remove_update_listener: Callable[[], Any]
|
remove_update_listener: Callable[[], Any]
|
||||||
|
remove_time_change_listener: Callable[[], Any]
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||||
|
"""Return every Home Assistant owner-account user id.
|
||||||
|
|
||||||
|
The last-resort admin seed: when no persisted allowlist copy and no
|
||||||
|
flow-context user survive to first setup, the HA owner accounts are
|
||||||
|
seeded so the household is never left with zero admins (an empty
|
||||||
|
allowlist fails closed, which would lock the household out of its
|
||||||
|
own integration). Owner accounts can already do everything in HA,
|
||||||
|
so granting NestQuest admin is not a privilege escalation.
|
||||||
|
"""
|
||||||
|
auth = getattr(hass, "auth", None)
|
||||||
|
if auth is None:
|
||||||
|
return []
|
||||||
|
users = await auth.async_get_users()
|
||||||
|
owners = [
|
||||||
|
user.id
|
||||||
|
for user in users
|
||||||
|
if getattr(user, "is_owner", False) and getattr(user, "id", None)
|
||||||
|
]
|
||||||
|
return owners
|
||||||
|
|
||||||
|
|
||||||
|
def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
|
||||||
|
"""Return the (hour, minute) the daily rollover fires at from ``options``.
|
||||||
|
|
||||||
|
The options flow validates ``day_rollover_time`` as a strict ``HH:MM``
|
||||||
|
string, so a stored value never needs re-validation here; a missing key
|
||||||
|
falls back to :data:`~.const.DEFAULT_DAY_ROLLOVER_TIME`.
|
||||||
|
"""
|
||||||
|
rollover = options.get(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME)
|
||||||
|
hour, _, minute = rollover.partition(":")
|
||||||
|
return int(hour), int(minute)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_day_rollover_listener(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
options: dict[str, Any],
|
||||||
|
) -> Callable[[], Any]:
|
||||||
|
"""Register the daily materialization listener and return its remover.
|
||||||
|
|
||||||
|
Fires once per day at the configured local ``day_rollover_time`` and
|
||||||
|
materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``.
|
||||||
|
"today" is computed in HA local time (``hass.config.time_zone``) rather
|
||||||
|
than the system clock, so the horizon tracks the household's own day.
|
||||||
|
The walk itself runs through :func:`~.materialize.materialize`, whose DB
|
||||||
|
access is executor-only.
|
||||||
|
"""
|
||||||
|
hour, minute = _day_rollover_hour_minute(options)
|
||||||
|
|
||||||
|
async def _run_materialization(_now: datetime.datetime) -> None:
|
||||||
|
time_zone = ZoneInfo(hass.config.time_zone)
|
||||||
|
today = datetime.datetime.now(time_zone).date()
|
||||||
|
start_date = today.isoformat()
|
||||||
|
end_date = (
|
||||||
|
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
|
||||||
|
).isoformat()
|
||||||
|
await _materialize_run(database, start_date, end_date)
|
||||||
|
|
||||||
|
return async_track_time_change(
|
||||||
|
hass, _run_materialization, hour=hour, minute=minute
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
@@ -241,10 +318,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
# record without a live connection (a previous setup's
|
# record without a live connection (a previous setup's
|
||||||
# close-on-failure, or a hand-replaced file) must get the
|
# close-on-failure, or a hand-replaced file) must get the
|
||||||
# missing/valid/corrupt classification too, never a raw
|
# missing/valid/corrupt classification too, never a raw
|
||||||
# sqlite3 error and never skipped migrations.
|
# sqlite3 error and never skipped migrations — and the
|
||||||
|
# same allowlist seeding, so a reopened fresh file is
|
||||||
|
# never ownerless.
|
||||||
db = await _async_open_database(
|
db = await _async_open_database(
|
||||||
hass, await async_get_db_path(hass)
|
hass, await async_get_db_path(hass)
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
stored_admin_ids = (
|
||||||
|
entry.options.get(CONF_ADMIN_USER_IDS)
|
||||||
|
or entry.data.get(CONF_ADMIN_USER_IDS)
|
||||||
|
)
|
||||||
|
context = getattr(entry, "context", None)
|
||||||
|
context_user_id = (
|
||||||
|
context.get("user_id")
|
||||||
|
if isinstance(context, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
await seed_setup_admin(
|
||||||
|
db,
|
||||||
|
stored_admin_ids=stored_admin_ids,
|
||||||
|
context_user_id=context_user_id,
|
||||||
|
owner_ids=await _async_owner_user_ids(hass),
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
# A seeding failure must not leak the just-opened
|
||||||
|
# connection: close it before the error propagates, so
|
||||||
|
# HA's setup retry starts from a clean handle (the
|
||||||
|
# first-setup path closes symmetrically).
|
||||||
|
await db.close()
|
||||||
|
raise
|
||||||
existing.database = db
|
existing.database = db
|
||||||
entry.runtime_data = existing
|
entry.runtime_data = existing
|
||||||
return True
|
return True
|
||||||
@@ -257,7 +360,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
database = await _async_open_database(hass, await async_get_db_path(hass))
|
database = await _async_open_database(hass, await async_get_db_path(hass))
|
||||||
try:
|
try:
|
||||||
|
# Seed the admin allowlist on first setup so the owner is never
|
||||||
|
# locked out: an empty database takes the persisted admin copy
|
||||||
|
# — the options flow's saved list first (it is the current
|
||||||
|
# one), else the config flow's user from entry data — and only
|
||||||
|
# then the HA user still present on the entry context. No-op
|
||||||
|
# on a non-empty list (fail-closed narrowing is deliberate).
|
||||||
|
stored_admin_ids = (
|
||||||
|
entry.options.get(CONF_ADMIN_USER_IDS)
|
||||||
|
or entry.data.get(CONF_ADMIN_USER_IDS)
|
||||||
|
)
|
||||||
|
context = getattr(entry, "context", None)
|
||||||
|
context_user_id = (
|
||||||
|
context.get("user_id")
|
||||||
|
if isinstance(context, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
owner_ids = await _async_owner_user_ids(hass)
|
||||||
|
await seed_setup_admin(
|
||||||
|
database,
|
||||||
|
stored_admin_ids=stored_admin_ids,
|
||||||
|
context_user_id=context_user_id,
|
||||||
|
owner_ids=owner_ids,
|
||||||
|
)
|
||||||
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
||||||
|
remove_time_change_listener = _register_day_rollover_listener(
|
||||||
|
hass, database, dict(entry.options)
|
||||||
|
)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
await database.close()
|
await database.close()
|
||||||
raise
|
raise
|
||||||
@@ -266,6 +395,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
options=dict(entry.options),
|
options=dict(entry.options),
|
||||||
database=database,
|
database=database,
|
||||||
remove_update_listener=remove_update_listener,
|
remove_update_listener=remove_update_listener,
|
||||||
|
remove_time_change_listener=remove_time_change_listener,
|
||||||
)
|
)
|
||||||
entry.runtime_data = runtime_data
|
entry.runtime_data = runtime_data
|
||||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||||
@@ -282,14 +412,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
runtime_data = getattr(entry, "runtime_data", None)
|
runtime_data = getattr(entry, "runtime_data", None)
|
||||||
if runtime_data is not None:
|
if runtime_data is not None:
|
||||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||||
|
remove_time_change_listener = getattr(
|
||||||
|
runtime_data, "remove_time_change_listener", None
|
||||||
|
)
|
||||||
unload_error: BaseException | None = None
|
unload_error: BaseException | None = None
|
||||||
try:
|
try:
|
||||||
if remove_update_listener is not None:
|
for remove_listener in (
|
||||||
result = remove_update_listener()
|
remove_update_listener,
|
||||||
if inspect.isawaitable(result):
|
remove_time_change_listener,
|
||||||
await result
|
):
|
||||||
except BaseException as err:
|
if remove_listener is None:
|
||||||
unload_error = err
|
continue
|
||||||
|
try:
|
||||||
|
result = remove_listener()
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
except BaseException as err:
|
||||||
|
# Cancel EVERY listener even when an earlier remover
|
||||||
|
# raises: a skipped remover would leave a daily
|
||||||
|
# callback firing against a closed database after
|
||||||
|
# unload. The first error is retained and re-raised
|
||||||
|
# only after both removers (and the DB close) ran.
|
||||||
|
if unload_error is None:
|
||||||
|
unload_error = err
|
||||||
finally:
|
finally:
|
||||||
database = getattr(runtime_data, "database", None)
|
database = getattr(runtime_data, "database", None)
|
||||||
if database is not None:
|
if database is not None:
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Business layer for the admin allowlist (Feature 03).
|
||||||
|
|
||||||
|
Sits between the options flow / Feature 09 service gate and the typed
|
||||||
|
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
|
||||||
|
Assistant user IDs — never usernames or display names (feature
|
||||||
|
guardrail) — and an empty allowlist must fail closed: every function
|
||||||
|
here refuses to write an empty allowlist, so the owner can never be
|
||||||
|
accidentally locked out of admin operations by a bad save.
|
||||||
|
|
||||||
|
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
|
||||||
|
in one write are rejected up front — they would silently map to one
|
||||||
|
row and hide a caller bug. Writes replace the allowlist atomically:
|
||||||
|
the diff (additions and removals) is computed and applied inside one
|
||||||
|
transaction under the connection-scoped lock shared with the DAOs, so
|
||||||
|
a concurrent reader never observes a half-applied sync.
|
||||||
|
|
||||||
|
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
|
||||||
|
when it is EMPTY, so re-running setup never resurrects a deliberately
|
||||||
|
narrowed list. The setup user (the HA user who completed the config
|
||||||
|
flow, via the entry context) is added automatically so the owner is
|
||||||
|
never locked out on first setup; a stored entry-data copy of the list
|
||||||
|
re-seeds a lost database.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .dao_children import AdminUsersDao, _connection_lock
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp() -> str:
|
||||||
|
"""UTC second-precision stamp, matching the registry's policy."""
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_user_id(value: object) -> str:
|
||||||
|
"""Return ``value`` trimmed; raise unless a non-empty string."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"admin user id must be a string, got {value!r}"
|
||||||
|
)
|
||||||
|
trimmed = value.strip()
|
||||||
|
if not trimmed:
|
||||||
|
raise ValueError("admin user id must not be empty")
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
|
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
|
||||||
|
"""Return the allowlisted HA user IDs in storage order."""
|
||||||
|
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
|
||||||
|
|
||||||
|
|
||||||
|
async def set_admin_ids(
|
||||||
|
database: NestQuestDatabase, user_ids: list[str]
|
||||||
|
) -> None:
|
||||||
|
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
|
||||||
|
|
||||||
|
Refuses an empty list — an empty allowlist must fail closed, and
|
||||||
|
the only way to reach one through this API would be removing the
|
||||||
|
last admin, so that removal is refused here with a clear error.
|
||||||
|
Refuses non-string, empty, or duplicate ids up front. The diff
|
||||||
|
against the current allowlist (additions and removals) applies
|
||||||
|
inside one transaction under the connection lock.
|
||||||
|
"""
|
||||||
|
if not isinstance(user_ids, list):
|
||||||
|
raise ValueError("user_ids must be a list of HA user id strings")
|
||||||
|
normalized: list[str] = []
|
||||||
|
for value in user_ids:
|
||||||
|
normalized.append(_validate_user_id(value))
|
||||||
|
if len(set(normalized)) != len(normalized):
|
||||||
|
raise ValueError("user_ids must not contain duplicates")
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(
|
||||||
|
"refusing to empty the admin allowlist: at least one admin "
|
||||||
|
"must remain (an empty allowlist fails closed)"
|
||||||
|
)
|
||||||
|
dao = AdminUsersDao(database)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
async with database.transaction():
|
||||||
|
current = {row.ha_user_id for row in await dao.list()}
|
||||||
|
target = set(normalized)
|
||||||
|
for user_id in sorted(current - target):
|
||||||
|
await dao.remove(user_id)
|
||||||
|
for user_id in normalized:
|
||||||
|
await dao.add(user_id, _now_stamp())
|
||||||
|
|
||||||
|
|
||||||
|
async def is_admin(
|
||||||
|
database: NestQuestDatabase, user_id: object
|
||||||
|
) -> bool:
|
||||||
|
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
|
||||||
|
|
||||||
|
True only for an allowlisted user id. Everything else is False:
|
||||||
|
a non-allowlisted id, an unknown id, a None or non-string value
|
||||||
|
(a service call with no user context), and an empty allowlist
|
||||||
|
(nobody is admin rather than everybody). The Feature 09 gate
|
||||||
|
wraps every admin-only service with this verdict; it never raises
|
||||||
|
for malformed input — malformed means not admin.
|
||||||
|
"""
|
||||||
|
if not isinstance(user_id, str) or not user_id:
|
||||||
|
return False
|
||||||
|
return await AdminUsersDao(database).exists(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_setup_admin(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
*,
|
||||||
|
stored_admin_ids: list[str] | None = None,
|
||||||
|
context_user_id: str | None = None,
|
||||||
|
owner_ids: list[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Seed an empty allowlist so the owner is never locked out.
|
||||||
|
|
||||||
|
Only acts when the allowlist is EMPTY. Preference order: the
|
||||||
|
stored persisted copy of the list (the options flow's current
|
||||||
|
selection, then the config flow's original — disaster recovery for
|
||||||
|
a lost database), then the HA user who completed the config flow
|
||||||
|
(``context_user_id``), then — when no caller identity survived at
|
||||||
|
all — every Home Assistant OWNER account (``owner_ids``), so a
|
||||||
|
first setup whose flow context lost the user id still seeds a
|
||||||
|
working admin. Returns the allowlist after seeding. Non-empty
|
||||||
|
allowlists are returned untouched — re-running setup must never
|
||||||
|
resurrect a deliberately narrowed list.
|
||||||
|
"""
|
||||||
|
dao = AdminUsersDao(database)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
current = [row.ha_user_id for row in await dao.list()]
|
||||||
|
if current:
|
||||||
|
return current
|
||||||
|
candidates: list[str] = []
|
||||||
|
if stored_admin_ids is not None:
|
||||||
|
if not isinstance(stored_admin_ids, list):
|
||||||
|
raise ValueError(
|
||||||
|
"stored_admin_ids must be a list of HA user id strings"
|
||||||
|
)
|
||||||
|
candidates = [_validate_user_id(value) for value in stored_admin_ids]
|
||||||
|
elif context_user_id is not None:
|
||||||
|
candidates = [_validate_user_id(context_user_id)]
|
||||||
|
elif owner_ids:
|
||||||
|
if not isinstance(owner_ids, list):
|
||||||
|
raise ValueError(
|
||||||
|
"owner_ids must be a list of HA user id strings"
|
||||||
|
)
|
||||||
|
candidates = [_validate_user_id(value) for value in owner_ids]
|
||||||
|
for user_id in candidates:
|
||||||
|
await dao.add(user_id, _now_stamp())
|
||||||
|
return [row.ha_user_id for row in await dao.list()]
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Business layer for the children registry (Feature 03).
|
||||||
|
|
||||||
|
Sits between the Feature 09 service gate and the typed
|
||||||
|
:class:`~.dao_children.ChildrenDao`: callers get validation, name
|
||||||
|
policies and the duplicate-name warning here, and never talk to the
|
||||||
|
DAO or SQL directly. Children are never hard-deleted — deactivation
|
||||||
|
is a separate explicit operation (:func:`set_child_active`), so
|
||||||
|
completion history keeps its references (feature guardrail).
|
||||||
|
|
||||||
|
Name policy: the display name is required and trimmed; an empty or
|
||||||
|
whitespace-only name is rejected with ValueError. A duplicate display
|
||||||
|
name is ALLOWED (two children may legitimately share a nickname) but
|
||||||
|
logged as a warning so an accidental double-add is visible in the HA
|
||||||
|
log. The duplicate check is case-insensitive and whitespace-normalised
|
||||||
|
on both sides, because "ada" and "Ada " are the same name to a parent.
|
||||||
|
|
||||||
|
Colour and avatar are optional free-form strings, trimmed when present;
|
||||||
|
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
|
||||||
|
optional field back to NULL is deliberately rejected: the DAO's update
|
||||||
|
writes only provided fields (it cannot express NULL), so the business
|
||||||
|
layer refuses an explicit ``None`` instead of silently ignoring it.
|
||||||
|
Timestamps follow the module timestamp policy: the business layer
|
||||||
|
stamps UTC ISO-8601 at second precision so every row it creates is
|
||||||
|
uniform.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
||||||
|
#: in :func:`edit_child`.
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp() -> str:
|
||||||
|
"""Return the module's strict UTC second-precision stamp."""
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_text(
|
||||||
|
value: object, field: str, *, required: bool = False
|
||||||
|
) -> str | None:
|
||||||
|
"""Validate a free-text field and return it trimmed.
|
||||||
|
|
||||||
|
``None`` raises when ``required`` and otherwise returns None. A
|
||||||
|
non-string value is rejected; an empty result is rejected for
|
||||||
|
required fields (whitespace-only names are empty to a parent).
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
if required:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||||
|
trimmed = value.strip()
|
||||||
|
if required and not trimmed:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_sort_order(value: object) -> int:
|
||||||
|
"""Reject non-int sort orders (bools included) before the DAO."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"sort_order must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_child_id(value: object) -> int:
|
||||||
|
"""Reject non-int child ids (bools included) before any lookup.
|
||||||
|
|
||||||
|
SQLite binds Python bools as integers, so ``True`` would silently
|
||||||
|
address child 1 and a float would round — malformed service input
|
||||||
|
must never mutate the wrong profile.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def _warn_on_duplicate_name(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
display_name: str,
|
||||||
|
*,
|
||||||
|
exclude_child_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Log a warning when ``display_name`` duplicates an existing child.
|
||||||
|
|
||||||
|
Case-insensitive and whitespace-normalised on both sides. The
|
||||||
|
duplicate is still allowed — this only makes it visible; the log
|
||||||
|
line names both rows so an accidental double-add can be found.
|
||||||
|
``exclude_child_id`` removes the child being edited from the scan,
|
||||||
|
so an edit only warns when the result collides with a DIFFERENT
|
||||||
|
child's name — including case-only edits, where the self-comparison
|
||||||
|
would otherwise hide a real duplicate.
|
||||||
|
"""
|
||||||
|
lowered = display_name.casefold()
|
||||||
|
for child in await ChildrenDao(database).list_all():
|
||||||
|
if exclude_child_id is not None and child.id == exclude_child_id:
|
||||||
|
continue
|
||||||
|
if child.display_name.strip().casefold() == lowered:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Child display name %r duplicates existing child %d "
|
||||||
|
"(%r); allowed, but check this was intentional",
|
||||||
|
display_name,
|
||||||
|
child.id,
|
||||||
|
child.display_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def create_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
display_name: str,
|
||||||
|
*,
|
||||||
|
colour: str | None = None,
|
||||||
|
avatar_ref: str | None = None,
|
||||||
|
sort_order: int = 0,
|
||||||
|
) -> ChildRecord:
|
||||||
|
"""Create a child profile and return the stored record.
|
||||||
|
|
||||||
|
The display name is required, trimmed, and rejected when empty.
|
||||||
|
A duplicate display name (case-insensitive) is allowed but logged
|
||||||
|
as a warning. The scan and the INSERT run inside the
|
||||||
|
connection-scoped lock shared with the DAOs, so a concurrent
|
||||||
|
create of the same normalised name queues behind the first insert
|
||||||
|
and sees it — the duplicate still warns; it can never slip through
|
||||||
|
unlogged.
|
||||||
|
"""
|
||||||
|
name = _validate_text(display_name, "display_name", required=True)
|
||||||
|
assert name is not None
|
||||||
|
colour_value = _validate_text(colour, "colour")
|
||||||
|
avatar_value = _validate_text(avatar_ref, "avatar_ref")
|
||||||
|
order = _validate_sort_order(sort_order)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
await _warn_on_duplicate_name(database, name)
|
||||||
|
created = await ChildrenDao(database).create(
|
||||||
|
name,
|
||||||
|
_now_stamp(),
|
||||||
|
colour=colour_value,
|
||||||
|
avatar_ref=avatar_value,
|
||||||
|
sort_order=order,
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
async def edit_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
*,
|
||||||
|
display_name: str | object = _UNSET,
|
||||||
|
colour: str | None | object = _UNSET,
|
||||||
|
avatar_ref: str | None | object = _UNSET,
|
||||||
|
sort_order: int | object = _UNSET,
|
||||||
|
) -> ChildRecord:
|
||||||
|
"""Edit a child profile and return the updated record.
|
||||||
|
|
||||||
|
Arguments default to the module sentinel ``_UNSET`` meaning "leave
|
||||||
|
this field alone". An explicitly passed ``None`` for an optional
|
||||||
|
field raises: the DAO's update writes only provided fields and
|
||||||
|
cannot express NULL, so a silent no-op would mask the caller's
|
||||||
|
intent (see the module docstring). A provided display name is
|
||||||
|
validated like create's; the duplicate scan excludes only the child
|
||||||
|
being edited, so a case-only edit colliding with another child's
|
||||||
|
normalised name still warns. Raises ValueError when the child does
|
||||||
|
not exist.
|
||||||
|
|
||||||
|
Atomicity: the existence check, the duplicate scan, the UPDATE and
|
||||||
|
the readback all run inside the connection-scoped lock shared with
|
||||||
|
the DAOs, so another edit cannot slip between this call's UPDATE
|
||||||
|
and its readback — the returned record is exactly the one this
|
||||||
|
edit produced, not a later writer's.
|
||||||
|
"""
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
dao = ChildrenDao(database)
|
||||||
|
existing = await dao.get(child_id)
|
||||||
|
if existing is None:
|
||||||
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
updates: dict[str, object] = {}
|
||||||
|
if display_name is not _UNSET:
|
||||||
|
name = _validate_text(
|
||||||
|
display_name, "display_name", required=True
|
||||||
|
)
|
||||||
|
assert name is not None
|
||||||
|
await _warn_on_duplicate_name(
|
||||||
|
database, name, exclude_child_id=child_id
|
||||||
|
)
|
||||||
|
updates["display_name"] = name
|
||||||
|
if colour is not _UNSET:
|
||||||
|
value = _validate_text(colour, "colour")
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(
|
||||||
|
"clearing colour is not supported; pass a replacement "
|
||||||
|
"value or omit the field"
|
||||||
|
)
|
||||||
|
updates["colour"] = value
|
||||||
|
if avatar_ref is not _UNSET:
|
||||||
|
value = _validate_text(avatar_ref, "avatar_ref")
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(
|
||||||
|
"clearing avatar_ref is not supported; pass a "
|
||||||
|
"replacement value or omit the field"
|
||||||
|
)
|
||||||
|
updates["avatar_ref"] = value
|
||||||
|
if sort_order is not _UNSET:
|
||||||
|
updates["sort_order"] = _validate_sort_order(sort_order)
|
||||||
|
if updates:
|
||||||
|
await dao.update(child_id, **updates)
|
||||||
|
edited = await dao.get(child_id)
|
||||||
|
assert edited is not None
|
||||||
|
return edited
|
||||||
|
|
||||||
|
|
||||||
|
async def set_child_active(
|
||||||
|
database: NestQuestDatabase, child_id: int, is_active: bool
|
||||||
|
) -> ChildRecord:
|
||||||
|
"""Deactivate or reactivate a child; returns the updated record.
|
||||||
|
|
||||||
|
Deactivation hides the child from the panel without deleting their
|
||||||
|
history (feature guardrail); this is the only removal path.
|
||||||
|
``is_active`` must be a real bool: the DAO's ``int()`` would
|
||||||
|
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||||
|
state the caller never asked for. Raises ValueError when the
|
||||||
|
child does not exist.
|
||||||
|
|
||||||
|
Atomicity: existence check, UPDATE and readback run inside the
|
||||||
|
connection-scoped lock, so a concurrent opposite transition cannot
|
||||||
|
make this call report the other caller's value.
|
||||||
|
"""
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
dao = ChildrenDao(database)
|
||||||
|
if await dao.get(child_id) is None:
|
||||||
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
if not isinstance(is_active, bool):
|
||||||
|
raise ValueError(
|
||||||
|
f"is_active must be a real bool, got {is_active!r}"
|
||||||
|
)
|
||||||
|
await dao.set_active(child_id, is_active)
|
||||||
|
updated = await dao.get(child_id)
|
||||||
|
assert updated is not None
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
async def reorder_children(
|
||||||
|
database: NestQuestDatabase, ordered_ids: list[int]
|
||||||
|
) -> None:
|
||||||
|
"""Rewrite the children's panel display order in one transaction.
|
||||||
|
|
||||||
|
``ordered_ids`` must be a complete permutation of the children
|
||||||
|
table — every child id exactly once. A partial list (missing an
|
||||||
|
existing child), an unknown id, or a duplicate is rejected with
|
||||||
|
ValueError before any write, so the current order survives a
|
||||||
|
rejected call. The DAO enforces this inside its own transaction
|
||||||
|
(check and write are atomic under the connection lock); this
|
||||||
|
wrapper additionally rejects non-integer ids up front, since
|
||||||
|
``bool`` and floats would silently compare against stored integer
|
||||||
|
ids at the SQL layer.
|
||||||
|
"""
|
||||||
|
for child_id in ordered_ids:
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
await ChildrenDao(database).reorder(ordered_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_children(
|
||||||
|
database: NestQuestDatabase, *, active_only: bool = False
|
||||||
|
) -> list[ChildRecord]:
|
||||||
|
"""Return children in display order (sort_order, then id)."""
|
||||||
|
dao = ChildrenDao(database)
|
||||||
|
if active_only:
|
||||||
|
return await dao.list_active()
|
||||||
|
return await dao.list_all()
|
||||||
@@ -7,7 +7,7 @@ from homeassistant import config_entries
|
|||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import CONF_ADMIN_USER_IDS, DOMAIN
|
||||||
|
|
||||||
|
|
||||||
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
@@ -28,7 +28,14 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Handle the user step of the config flow."""
|
"""Handle the user step of the config flow.
|
||||||
|
|
||||||
|
The HA user completing the flow is persisted into the entry
|
||||||
|
data as the initial admin allowlist copy: real HA does not
|
||||||
|
guarantee the flow context survives to setup, and entry data is
|
||||||
|
the persisted record first setup seeds from — the owner is
|
||||||
|
never locked out.
|
||||||
|
"""
|
||||||
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
|
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
|
||||||
if self._async_in_progress() or self._async_current_entries():
|
if self._async_in_progress() or self._async_current_entries():
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
@@ -36,4 +43,12 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
if user_input is None:
|
if user_input is None:
|
||||||
return self.async_show_form(step_id="user")
|
return self.async_show_form(step_id="user")
|
||||||
|
|
||||||
return self.async_create_entry(title="NestQuest", data=user_input)
|
data = dict(user_input)
|
||||||
|
flow_user_id = (
|
||||||
|
self.context.get("user_id")
|
||||||
|
if isinstance(self.context, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if flow_user_id:
|
||||||
|
data[CONF_ADMIN_USER_IDS] = [flow_user_id]
|
||||||
|
return self.async_create_entry(title="NestQuest", data=data)
|
||||||
@@ -21,6 +21,12 @@ DEFAULT_DAY_ROLLOVER_TIME = "00:00"
|
|||||||
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
|
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
|
||||||
DEFAULT_PANEL_IDLE_TIMEOUT = 300
|
DEFAULT_PANEL_IDLE_TIMEOUT = 300
|
||||||
|
|
||||||
|
# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only
|
||||||
|
# services. Stored as IDs, never usernames (feature guardrail). The
|
||||||
|
# database allowlist is authoritative; the config entry carries the same
|
||||||
|
# list as a disaster-recovery copy so a lost database can be re-seeded.
|
||||||
|
CONF_ADMIN_USER_IDS = "admin_user_ids"
|
||||||
|
|
||||||
# Quest windows (D-008): a definition may declare several day windows;
|
# Quest windows (D-008): a definition may declare several day windows;
|
||||||
# each produces its own instance per day and groups the panel's Quest
|
# each produces its own instance per day and groups the panel's Quest
|
||||||
# Log into three columns. The clock ranges are stored here so they stay
|
# Log into three columns. The clock ranges are stored here so they stay
|
||||||
|
|||||||
@@ -191,13 +191,20 @@ class ChildrenDao:
|
|||||||
async def reorder(self, ordered_ids: list[int]) -> None:
|
async def reorder(self, ordered_ids: list[int]) -> None:
|
||||||
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
|
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
|
||||||
|
|
||||||
|
The list must be a COMPLETE permutation of the children table:
|
||||||
|
every existing child id appears exactly once. Partial lists
|
||||||
|
(missing an existing child) and unknown ids (naming a child
|
||||||
|
that does not exist) are rejected with ValueError BEFORE any
|
||||||
|
write, leaving the current order untouched — a silent partial
|
||||||
|
reorder would leave unlisted children with stale positions and
|
||||||
|
hide a caller bug. Duplicate ids are rejected up front: they
|
||||||
|
cannot all hold distinct positions, and silently assigning the
|
||||||
|
last occurrence's index would hide a caller bug.
|
||||||
|
|
||||||
Runs inside one transaction; positions are staged at offset
|
Runs inside one transaction; positions are staged at offset
|
||||||
-len (all negative, so any permutation of the same ids never
|
-len (all negative, so any permutation of the same ids never
|
||||||
collides mid-reorder under the wrapper's single-connection
|
collides mid-reorder under the wrapper's single-connection
|
||||||
serialization), then finalized 0..n-1. Duplicate ids are
|
serialization), then finalized 0..n-1.
|
||||||
rejected up front: they cannot all hold distinct positions, and
|
|
||||||
silently assigning the last occurrence's index would hide a
|
|
||||||
caller bug.
|
|
||||||
|
|
||||||
Concurrent reorder() calls on one connection are serialized by
|
Concurrent reorder() calls on one connection are serialized by
|
||||||
a connection-scoped asyncio lock (shared across every ChildrenDao
|
a connection-scoped asyncio lock (shared across every ChildrenDao
|
||||||
@@ -205,10 +212,10 @@ class ChildrenDao:
|
|||||||
transaction() rejects a second concurrent transaction with
|
transaction() rejects a second concurrent transaction with
|
||||||
RuntimeError, so racing callers would crash instead of
|
RuntimeError, so racing callers would crash instead of
|
||||||
queueing. The lock makes the second call wait, then run
|
queueing. The lock makes the second call wait, then run
|
||||||
against the first call's final state.
|
against the first call's final state — which also keeps the
|
||||||
|
completeness check honest: the re-read of the table happens
|
||||||
|
inside the same lock and transaction as the writes.
|
||||||
"""
|
"""
|
||||||
if not ordered_ids:
|
|
||||||
return
|
|
||||||
if len(set(ordered_ids)) != len(ordered_ids):
|
if len(set(ordered_ids)) != len(ordered_ids):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"reorder() received duplicate child ids; every id must "
|
"reorder() received duplicate child ids; every id must "
|
||||||
@@ -216,6 +223,29 @@ class ChildrenDao:
|
|||||||
)
|
)
|
||||||
async with _connection_lock(self._database):
|
async with _connection_lock(self._database):
|
||||||
async with self._database.transaction():
|
async with self._database.transaction():
|
||||||
|
rows = await self._database.fetch_all(
|
||||||
|
f"SELECT id FROM children"
|
||||||
|
)
|
||||||
|
existing = {row[0] for row in rows}
|
||||||
|
listed = set(ordered_ids)
|
||||||
|
missing = sorted(existing - listed)
|
||||||
|
unknown = sorted(listed - existing)
|
||||||
|
if missing or unknown:
|
||||||
|
details = []
|
||||||
|
if missing:
|
||||||
|
details.append(
|
||||||
|
f"missing child ids {missing} (a partial "
|
||||||
|
"reorder would leave them with stale "
|
||||||
|
"positions)"
|
||||||
|
)
|
||||||
|
if unknown:
|
||||||
|
details.append(
|
||||||
|
f"unknown child ids {unknown}"
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"reorder() must list every child exactly once; "
|
||||||
|
+ "; ".join(details)
|
||||||
|
)
|
||||||
offset = len(ordered_ids)
|
offset = len(ordered_ids)
|
||||||
await self._database.execute_many(
|
await self._database.execute_many(
|
||||||
"UPDATE children SET sort_order = ? WHERE id = ?",
|
"UPDATE children SET sort_order = ? WHERE id = ?",
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ from .db import NestQuestDatabase
|
|||||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _today() -> datetime.date:
|
||||||
|
"""Return the current local calendar date (the single date source).
|
||||||
|
|
||||||
|
Every "today" read in this module goes through this one function so
|
||||||
|
the delete clamp and the materialize walk's no-past guard read the
|
||||||
|
same clock, and a caller or test can pin/roll that clock
|
||||||
|
deterministically (e.g. a queued-across-midnight regression). Pure
|
||||||
|
clock read — no business rule lives here.
|
||||||
|
"""
|
||||||
|
return datetime.date.today()
|
||||||
|
|
||||||
|
|
||||||
def _validate_utc_timestamp(value: str, field: str) -> None:
|
def _validate_utc_timestamp(value: str, field: str) -> None:
|
||||||
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
|
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
|
||||||
|
|
||||||
@@ -106,6 +118,7 @@ class CompletionEventRecord:
|
|||||||
event_type: str
|
event_type: str
|
||||||
actor_source: str
|
actor_source: str
|
||||||
actor_user_id: str | None
|
actor_user_id: str | None
|
||||||
|
actor_child_id: int | None
|
||||||
occurred_at: str
|
occurred_at: str
|
||||||
was_on_time: bool | None
|
was_on_time: bool | None
|
||||||
|
|
||||||
@@ -115,7 +128,7 @@ _INSTANCE_COLUMNS = (
|
|||||||
)
|
)
|
||||||
_EVENT_COLUMNS = (
|
_EVENT_COLUMNS = (
|
||||||
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
|
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
|
||||||
"occurred_at, was_on_time"
|
"actor_child_id, occurred_at, was_on_time"
|
||||||
)
|
)
|
||||||
|
|
||||||
#: The only event types the append path accepts; the schema CHECK is
|
#: The only event types the append path accepts; the schema CHECK is
|
||||||
@@ -144,8 +157,9 @@ def _event_from_row(row: tuple) -> CompletionEventRecord:
|
|||||||
event_type=row[3],
|
event_type=row[3],
|
||||||
actor_source=row[4],
|
actor_source=row[4],
|
||||||
actor_user_id=row[5],
|
actor_user_id=row[5],
|
||||||
occurred_at=row[6],
|
actor_child_id=row[6],
|
||||||
was_on_time=None if row[7] is None else bool(row[7]),
|
occurred_at=row[7],
|
||||||
|
was_on_time=None if row[8] is None else bool(row[8]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -201,7 +215,7 @@ class QuestInstancesDao:
|
|||||||
# check runs after the lock is acquired, because a caller can
|
# check runs after the lock is acquired, because a caller can
|
||||||
# wait on the lock across midnight — a date that was "today"
|
# wait on the lock across midnight — a date that was "today"
|
||||||
# at call time may be "yesterday" by the time the INSERT runs.
|
# at call time may be "yesterday" by the time the INSERT runs.
|
||||||
today = datetime.date.today().isoformat()
|
today = _today().isoformat()
|
||||||
if due_date < today:
|
if due_date < today:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"instances are never generated in the past: due_date "
|
f"instances are never generated in the past: due_date "
|
||||||
@@ -212,7 +226,7 @@ class QuestInstancesDao:
|
|||||||
# Re-read "today" under the lock: this is the date the
|
# Re-read "today" under the lock: this is the date the
|
||||||
# insert actually executes on, so the no-past rule
|
# insert actually executes on, so the no-past rule
|
||||||
# holds across midnight rollovers.
|
# holds across midnight rollovers.
|
||||||
today = datetime.date.today().isoformat()
|
today = _today().isoformat()
|
||||||
if due_date < today:
|
if due_date < today:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"instances are never generated in the past: "
|
f"instances are never generated in the past: "
|
||||||
@@ -275,6 +289,121 @@ class QuestInstancesDao:
|
|||||||
assert instance is not None
|
assert instance is not None
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
async def upsert_if_valid(
|
||||||
|
self,
|
||||||
|
definition_id: int,
|
||||||
|
child_id: int,
|
||||||
|
due_date: str,
|
||||||
|
generated_at: str,
|
||||||
|
*,
|
||||||
|
window: str,
|
||||||
|
due_time: str | None = None,
|
||||||
|
) -> QuestInstanceRecord | None:
|
||||||
|
"""Insert one dated instance iff its tuple is still materializable.
|
||||||
|
|
||||||
|
This is the materialization walk's single write path. Unlike the
|
||||||
|
generic :meth:`upsert`, which validates only existence and
|
||||||
|
assignment, this method re-validates EVERY materialization
|
||||||
|
precondition in the SAME connection-lock-held transaction as the
|
||||||
|
INSERT — closing the check-then-insert gap a concurrent config
|
||||||
|
write could otherwise slip through:
|
||||||
|
|
||||||
|
- the definition exists AND is active (a definition deactivated
|
||||||
|
after the input snapshot is skipped, never written);
|
||||||
|
- the child exists AND is active (a deactivated child is skipped);
|
||||||
|
- the (definition, child) assignment link still exists (a removed
|
||||||
|
assignment is skipped);
|
||||||
|
- the window is still declared on the definition (a removed window
|
||||||
|
is skipped).
|
||||||
|
|
||||||
|
The live checks above ONLY decide whether the tuple is still
|
||||||
|
valid; they never read the window's ``due_time``. The instance is
|
||||||
|
written with the caller-supplied ``due_time`` — the value snapshotted
|
||||||
|
from the coherent input read — so a window-time edit landing
|
||||||
|
mid-walk cannot leak a newer time into a batch that was already
|
||||||
|
snapshotted against older rule/presence/assignment decisions.
|
||||||
|
|
||||||
|
Returns the stored instance, or ``None`` when a precondition
|
||||||
|
failed — the tuple no longer materializable, which the walk SKIPS
|
||||||
|
rather than raising, so the rest of the batch is unaffected. The
|
||||||
|
no-past rule (instances are never generated in the past) is
|
||||||
|
preserved from :meth:`upsert` and still raises ValueError. An
|
||||||
|
instance that ALREADY has a completion event is also skipped
|
||||||
|
(returns ``None``) rather than raised: it is immutable, so the
|
||||||
|
walk leaves it untouched instead of failing the batch — the
|
||||||
|
generic :meth:`upsert` keeps its hard refusal for the
|
||||||
|
delete/regenerate path.
|
||||||
|
"""
|
||||||
|
_validate_date(due_date, "due_date")
|
||||||
|
_validate_window(window)
|
||||||
|
# Fail fast on obviously-past dates before queuing on the lock;
|
||||||
|
# the authoritative check re-runs under the lock (see upsert).
|
||||||
|
today = _today().isoformat()
|
||||||
|
if due_date < today:
|
||||||
|
raise ValueError(
|
||||||
|
f"instances are never generated in the past: due_date "
|
||||||
|
f"{due_date!r} is before today {today!r}"
|
||||||
|
)
|
||||||
|
async with _connection_lock(self._database):
|
||||||
|
async with self._database.transaction():
|
||||||
|
today = _today().isoformat()
|
||||||
|
if due_date < today:
|
||||||
|
raise ValueError(
|
||||||
|
f"instances are never generated in the past: "
|
||||||
|
f"due_date {due_date!r} is before today {today!r}"
|
||||||
|
)
|
||||||
|
still_valid = await self._database.fetch_one(
|
||||||
|
"SELECT 1 FROM quest_definitions d "
|
||||||
|
"JOIN quest_definition_assignees a "
|
||||||
|
"ON a.definition_id = d.id "
|
||||||
|
"JOIN children c ON c.id = a.child_id "
|
||||||
|
"JOIN quest_definition_windows w "
|
||||||
|
"ON w.definition_id = d.id "
|
||||||
|
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
|
||||||
|
"AND d.is_active = 1 AND c.is_active = 1 LIMIT 1",
|
||||||
|
(definition_id, child_id, window),
|
||||||
|
)
|
||||||
|
if still_valid is None:
|
||||||
|
# A config change invalidated the tuple since the
|
||||||
|
# snapshot: skip it rather than abort the batch.
|
||||||
|
return None
|
||||||
|
existing = await self._database.fetch_one(
|
||||||
|
"SELECT 1 FROM completion_events "
|
||||||
|
"WHERE instance_id = (SELECT id FROM quest_instances "
|
||||||
|
"WHERE definition_id = ? AND child_id = ? "
|
||||||
|
"AND due_date = ? AND window = ?)",
|
||||||
|
(definition_id, child_id, due_date, window),
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
# The instance is already completed and therefore
|
||||||
|
# immutable: the walk SKIPS it (no-op) rather than
|
||||||
|
# rewriting its snapshot columns or failing the
|
||||||
|
# batch. This is the materialization walk's skip,
|
||||||
|
# distinct from the generic :meth:`upsert` refusal.
|
||||||
|
return None
|
||||||
|
await self._database.execute(
|
||||||
|
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||||
|
"window, due_date, due_time, generated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT (definition_id, child_id, due_date, "
|
||||||
|
"window) DO UPDATE SET "
|
||||||
|
"due_time = excluded.due_time, "
|
||||||
|
"generated_at = excluded.generated_at",
|
||||||
|
(
|
||||||
|
definition_id,
|
||||||
|
child_id,
|
||||||
|
window,
|
||||||
|
due_date,
|
||||||
|
due_time,
|
||||||
|
generated_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
instance = await self.get(
|
||||||
|
definition_id, child_id, due_date, window
|
||||||
|
)
|
||||||
|
assert instance is not None
|
||||||
|
return instance
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
self,
|
self,
|
||||||
definition_id: int,
|
definition_id: int,
|
||||||
@@ -358,7 +487,7 @@ class QuestInstancesDao:
|
|||||||
# midnight cannot delete instances that became past
|
# midnight cannot delete instances that became past
|
||||||
# while it waited (backdating is clamped to the
|
# while it waited (backdating is clamped to the
|
||||||
# execution date, not the call date).
|
# execution date, not the call date).
|
||||||
today = datetime.date.today().isoformat()
|
today = _today().isoformat()
|
||||||
effective_cutoff = max(cutoff_date, today)
|
effective_cutoff = max(cutoff_date, today)
|
||||||
result = await self._database.execute(
|
result = await self._database.execute(
|
||||||
"DELETE FROM quest_instances WHERE definition_id = ? "
|
"DELETE FROM quest_instances WHERE definition_id = ? "
|
||||||
@@ -370,6 +499,49 @@ class QuestInstancesDao:
|
|||||||
)
|
)
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|
||||||
|
async def delete_future_uncompleted_for_child(
|
||||||
|
self, child_id: int, cutoff_date: str
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Delete the child's open instances at/after cutoff, across every
|
||||||
|
definition, returning ``(deleted, effective_cutoff)``.
|
||||||
|
|
||||||
|
Child-scoped counterpart to :meth:`delete_future_uncompleted`,
|
||||||
|
used when a child's presence changes (Feature 09): only instances
|
||||||
|
at or after the cutoff with NO completion event are removed, inside
|
||||||
|
one transaction — an instance that gains a completion event
|
||||||
|
mid-flight cannot be deleted. The cutoff cannot be backdated below
|
||||||
|
today, for the same reason as the definition-scoped method (see its
|
||||||
|
docstring). Any instance with a completion event is never touched,
|
||||||
|
regardless of its date.
|
||||||
|
|
||||||
|
Returns the number deleted and the effective cutoff that was
|
||||||
|
actually applied (``max(cutoff_date, today)``, read under the
|
||||||
|
lock). The caller (:func:`~.materialize.regenerate_for_child`)
|
||||||
|
passes that effective cutoff back as its re-materialization anchor,
|
||||||
|
so the delete and the rebuild share ONE execution-day even when the
|
||||||
|
call is queued across midnight — instead of the caller re-reading a
|
||||||
|
stale "today" and materializing a window the delete already
|
||||||
|
superseded.
|
||||||
|
"""
|
||||||
|
_validate_date(cutoff_date, "cutoff_date")
|
||||||
|
async with _connection_lock(self._database):
|
||||||
|
async with self._database.transaction():
|
||||||
|
# "today" is read under the lock, matching the
|
||||||
|
# definition-scoped method: the DELETE executes on this
|
||||||
|
# date, so a caller queued across midnight cannot delete
|
||||||
|
# instances that became past while it waited.
|
||||||
|
today = _today().isoformat()
|
||||||
|
effective_cutoff = max(cutoff_date, today)
|
||||||
|
result = await self._database.execute(
|
||||||
|
"DELETE FROM quest_instances WHERE child_id = ? "
|
||||||
|
"AND due_date >= ? AND NOT EXISTS ("
|
||||||
|
" SELECT 1 FROM completion_events "
|
||||||
|
" WHERE instance_id = quest_instances.id"
|
||||||
|
")",
|
||||||
|
(child_id, effective_cutoff),
|
||||||
|
)
|
||||||
|
return result.rowcount, effective_cutoff
|
||||||
|
|
||||||
|
|
||||||
class CompletionEventsDao:
|
class CompletionEventsDao:
|
||||||
"""Typed append-only access to the ``completion_events`` table.
|
"""Typed append-only access to the ``completion_events`` table.
|
||||||
@@ -396,19 +568,21 @@ class CompletionEventsDao:
|
|||||||
was_on_time: bool | None,
|
was_on_time: bool | None,
|
||||||
*,
|
*,
|
||||||
actor_user_id: str | None = None,
|
actor_user_id: str | None = None,
|
||||||
|
actor_child_id: int | None = None,
|
||||||
) -> CompletionEventRecord:
|
) -> CompletionEventRecord:
|
||||||
"""Append one completion or un-completion event.
|
"""Append one completion or un-completion event.
|
||||||
|
|
||||||
``actor_source`` is 'user' (with ``actor_user_id`` set) or
|
``actor_source`` is 'user' (an admin action: ``actor_user_id``
|
||||||
'panel' (no user id — the tapped child is ``child_id``); the
|
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
|
||||||
actor-pair CHECK enforces this shape at the schema level.
|
id, and ``actor_child_id`` carrying the tapped child profile,
|
||||||
``occurred_at`` must be the module's one strict UTC shape
|
D-008); the actor-pair CHECKs enforce this shape at the schema
|
||||||
(YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range queries
|
level. ``occurred_at`` must be the module's one strict UTC
|
||||||
stay exact. ``was_on_time`` is required (True/False) for
|
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
|
||||||
completions; for un-completions it may be None or the reversed
|
queries stay exact. ``was_on_time`` is required (True/False)
|
||||||
completion's flag (Feature 08 still deciding). The instance
|
for completions; for un-completions it may be None or the
|
||||||
and child must exist — validated atomically under the
|
reversed completion's flag (Feature 08 still deciding). The
|
||||||
connection lock.
|
instance, child and — for panel events — the tapped child must
|
||||||
|
exist — validated atomically under the connection lock.
|
||||||
"""
|
"""
|
||||||
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -428,6 +602,15 @@ class CompletionEventsDao:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
"actor_source 'panel' must not carry actor_user_id"
|
"actor_source 'panel' must not carry actor_user_id"
|
||||||
)
|
)
|
||||||
|
if actor_source == "panel" and actor_child_id is None:
|
||||||
|
raise ValueError(
|
||||||
|
"actor_source 'panel' requires actor_child_id "
|
||||||
|
"(the tapped child profile)"
|
||||||
|
)
|
||||||
|
if actor_source == "user" and actor_child_id is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"actor_source 'user' must not carry actor_child_id"
|
||||||
|
)
|
||||||
if was_on_time is not None and type(was_on_time) is not bool:
|
if was_on_time is not None and type(was_on_time) is not bool:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"was_on_time must be True, False or None, got "
|
f"was_on_time must be True, False or None, got "
|
||||||
@@ -458,16 +641,28 @@ class CompletionEventsDao:
|
|||||||
)
|
)
|
||||||
if child is None:
|
if child is None:
|
||||||
raise ValueError(f"child {child_id} does not exist")
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
if actor_child_id is not None:
|
||||||
|
tapped = await self._database.fetch_one(
|
||||||
|
"SELECT 1 FROM children WHERE id = ?",
|
||||||
|
(actor_child_id,),
|
||||||
|
)
|
||||||
|
if tapped is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"actor_child_id {actor_child_id} does not "
|
||||||
|
"reference an existing child"
|
||||||
|
)
|
||||||
result = await self._database.execute(
|
result = await self._database.execute(
|
||||||
"INSERT INTO completion_events (instance_id, child_id, "
|
"INSERT INTO completion_events (instance_id, child_id, "
|
||||||
"event_type, actor_source, actor_user_id, occurred_at, "
|
"event_type, actor_source, actor_user_id, "
|
||||||
"was_on_time) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"actor_child_id, occurred_at, was_on_time) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
instance_id,
|
instance_id,
|
||||||
child_id,
|
child_id,
|
||||||
event_type,
|
event_type,
|
||||||
actor_source,
|
actor_source,
|
||||||
actor_user_id,
|
actor_user_id,
|
||||||
|
actor_child_id,
|
||||||
occurred_at,
|
occurred_at,
|
||||||
None if was_on_time is None else int(was_on_time),
|
None if was_on_time is None else int(was_on_time),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -186,6 +186,15 @@ class PresenceOverridesDao:
|
|||||||
The child must exist (validated atomically under the
|
The child must exist (validated atomically under the
|
||||||
connection lock). Date shape is validated strictly, and the
|
connection lock). Date shape is validated strictly, and the
|
||||||
schema CHECK rejects end < start.
|
schema CHECK rejects end < start.
|
||||||
|
|
||||||
|
Overlap policy (Feature 05): an override for a child may not
|
||||||
|
overlap another override of the SAME child — two different
|
||||||
|
present/absent answers for one date are unresolvable. The
|
||||||
|
overlap check (inclusive boundaries) runs inside the same
|
||||||
|
transaction as the INSERT, so a racing create cannot slip a
|
||||||
|
conflicting override between the check and the write; a
|
||||||
|
conflict raises ValueError naming the conflicting override's
|
||||||
|
id and range.
|
||||||
"""
|
"""
|
||||||
_validate_date(start_date, "start_date")
|
_validate_date(start_date, "start_date")
|
||||||
_validate_date(end_date, "end_date")
|
_validate_date(end_date, "end_date")
|
||||||
@@ -196,6 +205,29 @@ class PresenceOverridesDao:
|
|||||||
)
|
)
|
||||||
if child is None:
|
if child is None:
|
||||||
raise ValueError(f"child {child_id} does not exist")
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
# Only a well-ordered range has overlap semantics:
|
||||||
|
# inverted ranges fall through to the INSERT and hit
|
||||||
|
# the schema's end>=start CHECK, preserving the
|
||||||
|
# IntegrityError contract the matrix tests rely on.
|
||||||
|
if end_date >= start_date:
|
||||||
|
conflicting = await self._database.fetch_one(
|
||||||
|
f"SELECT {_OVERRIDE_COLUMNS} "
|
||||||
|
"FROM presence_overrides "
|
||||||
|
"WHERE child_id = ? AND end_date >= ? "
|
||||||
|
"AND start_date <= ? ORDER BY id LIMIT 1",
|
||||||
|
(child_id, start_date, end_date),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conflicting = None
|
||||||
|
if conflicting is not None:
|
||||||
|
conflict = _override_from_row(conflicting)
|
||||||
|
raise ValueError(
|
||||||
|
f"override for child {child_id} "
|
||||||
|
f"[{start_date}, {end_date}] overlaps existing "
|
||||||
|
f"override {conflict.id} "
|
||||||
|
f"[{conflict.start_date}, {conflict.end_date}] "
|
||||||
|
f"(is_present={conflict.is_present})"
|
||||||
|
)
|
||||||
result = await self._database.execute(
|
result = await self._database.execute(
|
||||||
"INSERT INTO presence_overrides (child_id, "
|
"INSERT INTO presence_overrides (child_id, "
|
||||||
"start_date, end_date, is_present, note) "
|
"start_date, end_date, is_present, note) "
|
||||||
@@ -245,3 +277,58 @@ class PresenceOverridesDao:
|
|||||||
(override_id,),
|
(override_id,),
|
||||||
)
|
)
|
||||||
return result.rowcount > 0
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def read_snapshot_unlocked(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_ids: list[int],
|
||||||
|
range_start: str,
|
||||||
|
range_end: str,
|
||||||
|
) -> tuple[
|
||||||
|
dict[int, PresenceScheduleRecord],
|
||||||
|
dict[int, list[PresenceOverrideRecord]],
|
||||||
|
]:
|
||||||
|
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
|
||||||
|
|
||||||
|
MUST be called inside the connection lock and an open transaction, so
|
||||||
|
the schedules and overrides returned here stay coherent with every
|
||||||
|
other table the caller reads inside that same transaction (the
|
||||||
|
materialization walk's single input snapshot does exactly that). The
|
||||||
|
date range comes already validated by the caller.
|
||||||
|
|
||||||
|
Returns ``(schedules, overrides)``:
|
||||||
|
|
||||||
|
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
|
||||||
|
for every child that HAS a schedule row. A child absent from the
|
||||||
|
mapping has no schedule, i.e. is present every day (Feature 05).
|
||||||
|
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
|
||||||
|
``[range_start, range_end]`` (inclusive boundaries), ordered by start
|
||||||
|
date; a child with no matching override is absent from the mapping.
|
||||||
|
|
||||||
|
``child_ids`` must name existing children (the caller derives them
|
||||||
|
from a trusted snapshot, within the same transaction).
|
||||||
|
"""
|
||||||
|
children = sorted(set(child_ids))
|
||||||
|
if not children:
|
||||||
|
return {}, {}
|
||||||
|
placeholders = ",".join("?" for _ in children)
|
||||||
|
schedule_rows = await database.fetch_all(
|
||||||
|
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
|
||||||
|
f"WHERE child_id IN ({placeholders})",
|
||||||
|
tuple(children),
|
||||||
|
)
|
||||||
|
override_rows = await database.fetch_all(
|
||||||
|
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||||
|
f"WHERE child_id IN ({placeholders}) AND end_date >= ? "
|
||||||
|
"AND start_date <= ? ORDER BY start_date",
|
||||||
|
tuple(children) + (range_start, range_end),
|
||||||
|
)
|
||||||
|
schedules = {
|
||||||
|
record.child_id: record
|
||||||
|
for record in map(_schedule_from_row, schedule_rows)
|
||||||
|
}
|
||||||
|
overrides: dict[int, list[PresenceOverrideRecord]] = {}
|
||||||
|
for row in override_rows:
|
||||||
|
record = _override_from_row(row)
|
||||||
|
overrides.setdefault(record.child_id, []).append(record)
|
||||||
|
return schedules, overrides
|
||||||
@@ -40,6 +40,7 @@ from .const import QUEST_WINDOWS
|
|||||||
from .dao_children import ChildRecord, _child_from_row, _connection_lock
|
from .dao_children import ChildRecord, _child_from_row, _connection_lock
|
||||||
from .dao_children import _CHILD_COLUMNS
|
from .dao_children import _CHILD_COLUMNS
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
|
from .recurrence import RuleType, RuleValidationError, ScheduleRule
|
||||||
|
|
||||||
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
||||||
#: in update methods: passing ``None`` must mean clearing a nullable
|
#: in update methods: passing ``None`` must mean clearing a nullable
|
||||||
@@ -119,6 +120,26 @@ class ScheduleRuleRecord:
|
|||||||
end_date: str | None
|
end_date: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScheduleRuleStorage:
|
||||||
|
"""The eight value columns a ScheduleRule maps to (no id column).
|
||||||
|
|
||||||
|
``id`` is assigned by the database on insert, so the model<->storage
|
||||||
|
mapping concerns only the value columns. ``nth_weekday_weekday`` is
|
||||||
|
deliberately absent here: for MONTHLY_WEEKDAY its value is folded
|
||||||
|
into ``weekday_set`` as that column's single CSV element.
|
||||||
|
"""
|
||||||
|
|
||||||
|
rule_type: str
|
||||||
|
interval: int
|
||||||
|
weekday_set: str | None
|
||||||
|
day_of_month: int | None
|
||||||
|
nth_weekday: int | None
|
||||||
|
month: int | None
|
||||||
|
start_date: str
|
||||||
|
end_date: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class QuestDefinitionRecord:
|
class QuestDefinitionRecord:
|
||||||
"""One row of ``quest_definitions`` (without its assignees)."""
|
"""One row of ``quest_definitions`` (without its assignees)."""
|
||||||
@@ -142,6 +163,24 @@ class QuestDefinitionWindowRecord:
|
|||||||
due_time: str | None
|
due_time: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QuestDefinitionSnapshot:
|
||||||
|
"""A definition plus its rule, assignees and windows, read atomically.
|
||||||
|
|
||||||
|
Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows`
|
||||||
|
and :meth:`QuestDefinitionsDao.edit_definition` with the rule,
|
||||||
|
assignees and windows read inside the SAME transaction that wrote
|
||||||
|
them, so the snapshot is a consistent creation/edit-time view — a
|
||||||
|
concurrent rule, assignee or window mutation cannot change it
|
||||||
|
between the write and the read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
definition: QuestDefinitionRecord
|
||||||
|
rule: ScheduleRuleRecord
|
||||||
|
assignees: list[ChildRecord]
|
||||||
|
windows: list[QuestDefinitionWindowRecord]
|
||||||
|
|
||||||
|
|
||||||
_RULE_COLUMNS = (
|
_RULE_COLUMNS = (
|
||||||
"id, rule_type, interval, weekday_set, day_of_month, nth_weekday, "
|
"id, rule_type, interval, weekday_set, day_of_month, nth_weekday, "
|
||||||
"month, start_date, end_date"
|
"month, start_date, end_date"
|
||||||
@@ -188,6 +227,250 @@ def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Storage columns that are forbidden for each rule type. A populated
|
||||||
|
#: forbidden column means the row's shape is invalid and must raise on
|
||||||
|
#: read, never be silently dropped. ``weekday_set`` is absent for
|
||||||
|
#: DAILY/MONTHLY_DAY/YEARLY; MONTHLY_WEEKDAY is the only shape allowed a
|
||||||
|
#: non-NULL ``nth_weekday``; YEARLY is the only shape allowed ``month``.
|
||||||
|
_FORBIDDEN_STORAGE_FIELDS: dict[str, tuple[RuleType, ...]] = {
|
||||||
|
"weekday_set": (RuleType.DAILY, RuleType.MONTHLY_DAY, RuleType.YEARLY),
|
||||||
|
"day_of_month": (
|
||||||
|
RuleType.DAILY,
|
||||||
|
RuleType.WEEKLY,
|
||||||
|
RuleType.CUSTOM_DAYS,
|
||||||
|
RuleType.MONTHLY_WEEKDAY,
|
||||||
|
),
|
||||||
|
"nth_weekday": (
|
||||||
|
RuleType.DAILY,
|
||||||
|
RuleType.WEEKLY,
|
||||||
|
RuleType.CUSTOM_DAYS,
|
||||||
|
RuleType.MONTHLY_DAY,
|
||||||
|
RuleType.YEARLY,
|
||||||
|
),
|
||||||
|
"month": (
|
||||||
|
RuleType.DAILY,
|
||||||
|
RuleType.WEEKLY,
|
||||||
|
RuleType.CUSTOM_DAYS,
|
||||||
|
RuleType.MONTHLY_DAY,
|
||||||
|
RuleType.MONTHLY_WEEKDAY,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_rule_to_storage(rule: ScheduleRule) -> ScheduleRuleStorage:
|
||||||
|
"""Map a validated ScheduleRule onto its storage columns.
|
||||||
|
|
||||||
|
``rule_type`` stores as ``RuleType.storage_value`` (MONTHLY_DAY and
|
||||||
|
MONTHLY_WEEKDAY both store ``'monthly'``); ``weekday_set`` stores as
|
||||||
|
a sorted CSV list. MONTHLY_WEEKDAY folds its model-only
|
||||||
|
``nth_weekday_weekday`` into ``weekday_set`` as that list's single
|
||||||
|
element; every other type leaves the column as its own set
|
||||||
|
(WEEKLY/CUSTOM_DAYS) or NULL.
|
||||||
|
"""
|
||||||
|
shape = rule.rule_type
|
||||||
|
if shape is RuleType.MONTHLY_WEEKDAY:
|
||||||
|
weekday_set = str(rule.nth_weekday_weekday)
|
||||||
|
elif shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
|
||||||
|
weekday_set = ",".join(str(w) for w in sorted(rule.weekday_set))
|
||||||
|
else:
|
||||||
|
weekday_set = None
|
||||||
|
return ScheduleRuleStorage(
|
||||||
|
rule_type=shape.storage_value,
|
||||||
|
interval=rule.interval,
|
||||||
|
weekday_set=weekday_set,
|
||||||
|
day_of_month=rule.day_of_month,
|
||||||
|
nth_weekday=rule.nth_weekday,
|
||||||
|
month=rule.month,
|
||||||
|
start_date=rule.start_date,
|
||||||
|
end_date=rule.end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule:
|
||||||
|
"""Reconstruct a ScheduleRule from storage columns.
|
||||||
|
|
||||||
|
Disambiguates MONTHLY_DAY from MONTHLY_WEEKDAY by which field is
|
||||||
|
populated, and raises RuleValidationError rather than silently
|
||||||
|
mis-decoding an ambiguous or invalid shape.
|
||||||
|
"""
|
||||||
|
rule_type = storage.rule_type
|
||||||
|
if rule_type == "daily":
|
||||||
|
shape = RuleType.DAILY
|
||||||
|
elif rule_type == "weekly":
|
||||||
|
shape = RuleType.WEEKLY
|
||||||
|
elif rule_type == "monthly":
|
||||||
|
shape = _disambiguate_monthly(storage)
|
||||||
|
elif rule_type == "yearly":
|
||||||
|
shape = RuleType.YEARLY
|
||||||
|
elif rule_type == "custom":
|
||||||
|
shape = RuleType.CUSTOM_DAYS
|
||||||
|
else:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"unknown storage rule_type {rule_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_reject_forbidden_storage_fields(storage, shape)
|
||||||
|
|
||||||
|
weekday_set = None
|
||||||
|
nth_weekday_weekday = None
|
||||||
|
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
|
||||||
|
weekday_set = _weekdays_from_csv(storage.weekday_set)
|
||||||
|
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||||
|
nth_weekday_weekday = _single_weekday_from_csv(storage.weekday_set)
|
||||||
|
|
||||||
|
return ScheduleRule(
|
||||||
|
rule_type=shape,
|
||||||
|
interval=storage.interval,
|
||||||
|
weekday_set=weekday_set,
|
||||||
|
day_of_month=storage.day_of_month,
|
||||||
|
nth_weekday=storage.nth_weekday,
|
||||||
|
nth_weekday_weekday=nth_weekday_weekday,
|
||||||
|
month=storage.month,
|
||||||
|
start_date=storage.start_date,
|
||||||
|
end_date=storage.end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_rule_storage_from_record(
|
||||||
|
record: ScheduleRuleRecord,
|
||||||
|
) -> ScheduleRuleStorage:
|
||||||
|
"""Copy a rule row's value columns into a storage mapping.
|
||||||
|
|
||||||
|
``ScheduleRuleRecord`` is the read shape (it carries ``id``);
|
||||||
|
:func:`schedule_rule_from_storage` consumes the value-only
|
||||||
|
:class:`ScheduleRuleStorage` shape, so callers re-reading a stored
|
||||||
|
rule convert through this helper.
|
||||||
|
"""
|
||||||
|
return ScheduleRuleStorage(
|
||||||
|
rule_type=record.rule_type,
|
||||||
|
interval=record.interval,
|
||||||
|
weekday_set=record.weekday_set,
|
||||||
|
day_of_month=record.day_of_month,
|
||||||
|
nth_weekday=record.nth_weekday,
|
||||||
|
month=record.month,
|
||||||
|
start_date=record.start_date,
|
||||||
|
end_date=record.end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_forbidden_storage_fields(
|
||||||
|
storage: ScheduleRuleStorage, shape: RuleType
|
||||||
|
) -> None:
|
||||||
|
"""Raise unless every populated column is permitted for ``shape``.
|
||||||
|
|
||||||
|
The model constructor rejects a forbidden ``day_of_month``,
|
||||||
|
``nth_weekday``, or ``month``, but ``weekday_set`` never reaches it
|
||||||
|
(it is folded for MONTHLY_WEEKDAY and dropped otherwise), so a daily
|
||||||
|
row carrying ``weekday_set="0"`` would otherwise decode as a valid
|
||||||
|
daily rule and silently lose the stored data. This validates every
|
||||||
|
shape column explicitly so a populated-forbidden column raises
|
||||||
|
instead of being discarded.
|
||||||
|
"""
|
||||||
|
for field, forbidden_shapes in _FORBIDDEN_STORAGE_FIELDS.items():
|
||||||
|
value = getattr(storage, field)
|
||||||
|
if value is not None and shape in forbidden_shapes:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"{shape.value} storage rows must not set {field}, "
|
||||||
|
f"got {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _disambiguate_monthly(storage: ScheduleRuleStorage) -> RuleType:
|
||||||
|
"""Resolve the shared 'monthly' storage value to a model type.
|
||||||
|
|
||||||
|
MONTHLY_DAY names ``day_of_month`` only; MONTHLY_WEEKDAY names
|
||||||
|
``nth_weekday`` only. Neither, or both, is ambiguous and raises.
|
||||||
|
"""
|
||||||
|
day_of_month = storage.day_of_month
|
||||||
|
nth_weekday = storage.nth_weekday
|
||||||
|
if day_of_month is not None and nth_weekday is None:
|
||||||
|
return RuleType.MONTHLY_DAY
|
||||||
|
if nth_weekday is not None and day_of_month is None:
|
||||||
|
return RuleType.MONTHLY_WEEKDAY
|
||||||
|
raise RuleValidationError(
|
||||||
|
"storage rule_type 'monthly' is ambiguous: must name exactly one "
|
||||||
|
"of day_of_month or nth_weekday, got "
|
||||||
|
f"day_of_month={day_of_month!r}, nth_weekday={nth_weekday!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _weekdays_from_csv(value: str | None) -> frozenset[int]:
|
||||||
|
"""Parse a weekday CSV column into a frozenset of ints.
|
||||||
|
|
||||||
|
``None`` and ``""`` both yield an empty frozenset (the caller decides
|
||||||
|
whether empty is legal); a non-integer token raises
|
||||||
|
RuleValidationError rather than leaking a raw ValueError.
|
||||||
|
"""
|
||||||
|
if value is None or value == "":
|
||||||
|
return frozenset()
|
||||||
|
try:
|
||||||
|
entries = [int(token) for token in value.split(",")]
|
||||||
|
except ValueError:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"weekday_set must be a CSV of integers, got {value!r}"
|
||||||
|
) from None
|
||||||
|
return frozenset(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _single_weekday_from_csv(value: str | None) -> int:
|
||||||
|
"""The one weekday a MONTHLY_WEEKDAY stores in weekday_set.
|
||||||
|
|
||||||
|
Parses the CSV into tokens FIRST and requires exactly one token,
|
||||||
|
so a duplicate pair like ``"1,1"`` is rejected rather than silently
|
||||||
|
canonicalized to a single weekday on re-storage. An empty or
|
||||||
|
multi-element column raises RuleValidationError.
|
||||||
|
"""
|
||||||
|
if value is None or value == "":
|
||||||
|
raise RuleValidationError(
|
||||||
|
"MONTHLY_WEEKDAY storage weekday_set must hold exactly one "
|
||||||
|
f"weekday, got {value!r}"
|
||||||
|
)
|
||||||
|
tokens = value.split(",")
|
||||||
|
if len(tokens) != 1:
|
||||||
|
raise RuleValidationError(
|
||||||
|
"MONTHLY_WEEKDAY storage weekday_set must hold exactly one "
|
||||||
|
f"weekday, got {value!r}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(tokens[0])
|
||||||
|
except ValueError:
|
||||||
|
raise RuleValidationError(
|
||||||
|
"MONTHLY_WEEKDAY storage weekday_set must be an integer, "
|
||||||
|
f"got {value!r}"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
async def _insert_rule_row(
|
||||||
|
database: NestQuestDatabase, storage: ScheduleRuleStorage
|
||||||
|
) -> int:
|
||||||
|
"""Insert one ``schedule_rules`` row and return its new id.
|
||||||
|
|
||||||
|
Runs inside the caller's transaction (no lock or transaction of its
|
||||||
|
own) so a multi-table write can persist the rule together with the
|
||||||
|
rows that reference it. The caller is responsible for validating
|
||||||
|
the storage fields — :meth:`ScheduleRulesDao.create` validates the
|
||||||
|
date shape, and :meth:`QuestDefinitionsDao.create_with_rule_and_windows`
|
||||||
|
receives storage already mapped from a validated
|
||||||
|
:class:`~.recurrence.ScheduleRule`.
|
||||||
|
"""
|
||||||
|
result = await database.execute(
|
||||||
|
"INSERT INTO schedule_rules (rule_type, interval, weekday_set, "
|
||||||
|
"day_of_month, nth_weekday, month, start_date, end_date) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
storage.rule_type,
|
||||||
|
storage.interval,
|
||||||
|
storage.weekday_set,
|
||||||
|
storage.day_of_month,
|
||||||
|
storage.nth_weekday,
|
||||||
|
storage.month,
|
||||||
|
storage.start_date,
|
||||||
|
storage.end_date,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return result.lastrowid
|
||||||
|
|
||||||
|
|
||||||
class ScheduleRulesDao:
|
class ScheduleRulesDao:
|
||||||
"""Typed async access to the ``schedule_rules`` table."""
|
"""Typed async access to the ``schedule_rules`` table."""
|
||||||
|
|
||||||
@@ -218,22 +501,20 @@ class ScheduleRulesDao:
|
|||||||
_validate_date(start_date, "start_date")
|
_validate_date(start_date, "start_date")
|
||||||
if end_date is not None:
|
if end_date is not None:
|
||||||
_validate_date(end_date, "end_date")
|
_validate_date(end_date, "end_date")
|
||||||
result = await self._database.execute(
|
rule_id = await _insert_rule_row(
|
||||||
"INSERT INTO schedule_rules (rule_type, interval, weekday_set, "
|
self._database,
|
||||||
"day_of_month, nth_weekday, month, start_date, end_date) "
|
ScheduleRuleStorage(
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
rule_type=rule_type,
|
||||||
(
|
interval=interval,
|
||||||
rule_type,
|
weekday_set=weekday_set,
|
||||||
interval,
|
day_of_month=day_of_month,
|
||||||
weekday_set,
|
nth_weekday=nth_weekday,
|
||||||
day_of_month,
|
month=month,
|
||||||
nth_weekday,
|
start_date=start_date,
|
||||||
month,
|
end_date=end_date,
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rule = await self.get(result.lastrowid)
|
rule = await self.get(rule_id)
|
||||||
assert rule is not None
|
assert rule is not None
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
@@ -393,6 +674,191 @@ class QuestDefinitionsDao:
|
|||||||
assert definition is not None
|
assert definition is not None
|
||||||
return definition
|
return definition
|
||||||
|
|
||||||
|
async def create_with_rule_and_windows(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
schedule_rule: ScheduleRuleStorage,
|
||||||
|
created_at: str,
|
||||||
|
assignee_child_ids: list[int],
|
||||||
|
windows: list[tuple[str, str | None]],
|
||||||
|
*,
|
||||||
|
description: str | None = None,
|
||||||
|
icon: str | None = None,
|
||||||
|
) -> QuestDefinitionSnapshot:
|
||||||
|
"""Insert a rule, definition, assignees and windows atomically.
|
||||||
|
|
||||||
|
The whole write — the schedule rule row, the definition row,
|
||||||
|
every assignee link and every window declaration — runs inside
|
||||||
|
ONE transaction under the connection lock, so a validation or
|
||||||
|
write failure rolls the lot back together (all-or-nothing): the
|
||||||
|
caller can never observe a definition with a missing rule,
|
||||||
|
assignee or window, and a rejected create leaves no partial
|
||||||
|
rows.
|
||||||
|
|
||||||
|
Returns a :class:`QuestDefinitionSnapshot` whose assignees and
|
||||||
|
windows are read back inside the same transaction, so the
|
||||||
|
returned view cannot be raced by a concurrent assignee/window
|
||||||
|
mutation after the write.
|
||||||
|
|
||||||
|
The rule arrives as pre-validated storage fields (mapped from a
|
||||||
|
:class:`~.recurrence.ScheduleRule` via
|
||||||
|
:func:`schedule_rule_to_storage`); its dates are re-validated
|
||||||
|
here so a hand-built storage can never persist a malformed date.
|
||||||
|
The definition-level ``due_time`` column is deliberately left
|
||||||
|
NULL — per-window due times supersede it (D-008). Assignees are
|
||||||
|
validated as existing-and-active at insert time, and windows are
|
||||||
|
validated for name and strict HH:MM due time, matching
|
||||||
|
:meth:`upsert_window`.
|
||||||
|
"""
|
||||||
|
_validate_date(schedule_rule.start_date, "start_date")
|
||||||
|
if schedule_rule.end_date is not None:
|
||||||
|
_validate_date(schedule_rule.end_date, "end_date")
|
||||||
|
async with _connection_lock(self._database):
|
||||||
|
async with self._database.transaction():
|
||||||
|
for child_id in assignee_child_ids:
|
||||||
|
await self._validate_assignable_child(child_id)
|
||||||
|
rule_id = await _insert_rule_row(self._database, schedule_rule)
|
||||||
|
result = await self._database.execute(
|
||||||
|
"INSERT INTO quest_definitions (title, description, "
|
||||||
|
"icon, schedule_rule_id, due_time, is_active, "
|
||||||
|
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(title, description, icon, rule_id, None, 1, created_at),
|
||||||
|
)
|
||||||
|
definition_id = result.lastrowid
|
||||||
|
for child_id in assignee_child_ids:
|
||||||
|
await self._database.execute(
|
||||||
|
"INSERT INTO quest_definition_assignees "
|
||||||
|
"(definition_id, child_id) VALUES (?, ?)",
|
||||||
|
(definition_id, child_id),
|
||||||
|
)
|
||||||
|
for window, due_time in windows:
|
||||||
|
_validate_window(window)
|
||||||
|
if due_time is not None:
|
||||||
|
_validate_time(due_time, "due_time")
|
||||||
|
await self._database.execute(
|
||||||
|
"INSERT INTO quest_definition_windows "
|
||||||
|
"(definition_id, window, due_time) VALUES (?, ?, ?)",
|
||||||
|
(definition_id, window, due_time),
|
||||||
|
)
|
||||||
|
definition = await self.get(definition_id)
|
||||||
|
assignees = await self.list_assignees(definition_id)
|
||||||
|
window_records = await self.list_windows(definition_id)
|
||||||
|
rule_record = await ScheduleRulesDao(self._database).get(
|
||||||
|
rule_id
|
||||||
|
)
|
||||||
|
assert definition is not None
|
||||||
|
assert rule_record is not None
|
||||||
|
return QuestDefinitionSnapshot(
|
||||||
|
definition=definition,
|
||||||
|
rule=rule_record,
|
||||||
|
assignees=assignees,
|
||||||
|
windows=window_records,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def edit_definition(
|
||||||
|
self,
|
||||||
|
definition_id: int,
|
||||||
|
*,
|
||||||
|
title: str | None | object = _UNSET,
|
||||||
|
description: str | None | object = _UNSET,
|
||||||
|
icon: str | None | object = _UNSET,
|
||||||
|
rule: ScheduleRuleStorage | object = _UNSET,
|
||||||
|
windows: list[tuple[str, str | None]] | object = _UNSET,
|
||||||
|
) -> QuestDefinitionSnapshot:
|
||||||
|
"""Edit a definition's metadata, rule and windows atomically.
|
||||||
|
|
||||||
|
Mirrors :meth:`create_with_rule_and_windows`: everything runs
|
||||||
|
inside ONE transaction under the connection lock, and the
|
||||||
|
returned snapshot's rule, assignees and windows are read back
|
||||||
|
inside that transaction. Arguments default to the module sentinel
|
||||||
|
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
|
||||||
|
NULL so optional metadata (description, icon) can be cleared.
|
||||||
|
|
||||||
|
- ``title``/``description``/``icon`` update the definition row
|
||||||
|
in place; the definition-level ``due_time`` column is never
|
||||||
|
written (per-window due times supersede it, D-008).
|
||||||
|
- ``rule`` (a pre-validated :class:`ScheduleRuleStorage`)
|
||||||
|
overwrites the referenced ``schedule_rules`` row in place: the
|
||||||
|
definition keeps its ``schedule_rule_id``, so no new or
|
||||||
|
orphaned rule row appears.
|
||||||
|
- ``windows`` REPLACES the whole window set: the current rows
|
||||||
|
are deleted and the given set re-inserted, so missing windows
|
||||||
|
are added, changed due times updated and removed windows
|
||||||
|
dropped.
|
||||||
|
|
||||||
|
Assignment is deliberately not settable here (multi-assignee is
|
||||||
|
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
|
||||||
|
activation is untouched. Raises ValueError when the definition
|
||||||
|
does not exist. Edits change future instances only: no
|
||||||
|
``quest_instances`` or ``completion_events`` row is touched.
|
||||||
|
"""
|
||||||
|
async with _connection_lock(self._database):
|
||||||
|
async with self._database.transaction():
|
||||||
|
definition = await self.get(definition_id)
|
||||||
|
if definition is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"quest definition {definition_id} does not exist"
|
||||||
|
)
|
||||||
|
assignments: list[str] = []
|
||||||
|
parameters: list[object] = []
|
||||||
|
for column, value in (
|
||||||
|
("title", title),
|
||||||
|
("description", description),
|
||||||
|
("icon", icon),
|
||||||
|
):
|
||||||
|
if value is not _UNSET:
|
||||||
|
assignments.append(f"{column} = ?")
|
||||||
|
parameters.append(value)
|
||||||
|
if assignments:
|
||||||
|
parameters.append(definition_id)
|
||||||
|
await self._database.execute(
|
||||||
|
f"UPDATE quest_definitions SET "
|
||||||
|
f"{', '.join(assignments)} WHERE id = ?",
|
||||||
|
tuple(parameters),
|
||||||
|
)
|
||||||
|
if rule is not _UNSET:
|
||||||
|
await ScheduleRulesDao(self._database).update(
|
||||||
|
definition.schedule_rule_id,
|
||||||
|
rule_type=rule.rule_type,
|
||||||
|
interval=rule.interval,
|
||||||
|
weekday_set=rule.weekday_set,
|
||||||
|
day_of_month=rule.day_of_month,
|
||||||
|
nth_weekday=rule.nth_weekday,
|
||||||
|
month=rule.month,
|
||||||
|
start_date=rule.start_date,
|
||||||
|
end_date=rule.end_date,
|
||||||
|
)
|
||||||
|
if windows is not _UNSET:
|
||||||
|
await self._database.execute(
|
||||||
|
"DELETE FROM quest_definition_windows "
|
||||||
|
"WHERE definition_id = ?",
|
||||||
|
(definition_id,),
|
||||||
|
)
|
||||||
|
for window, due_time in windows:
|
||||||
|
_validate_window(window)
|
||||||
|
if due_time is not None:
|
||||||
|
_validate_time(due_time, "due_time")
|
||||||
|
await self._database.execute(
|
||||||
|
"INSERT INTO quest_definition_windows "
|
||||||
|
"(definition_id, window, due_time) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
(definition_id, window, due_time),
|
||||||
|
)
|
||||||
|
updated = await self.get(definition_id)
|
||||||
|
assignees = await self.list_assignees(definition_id)
|
||||||
|
window_records = await self.list_windows(definition_id)
|
||||||
|
rule_record = await ScheduleRulesDao(self._database).get(
|
||||||
|
updated.schedule_rule_id
|
||||||
|
)
|
||||||
|
assert updated is not None
|
||||||
|
assert rule_record is not None
|
||||||
|
return QuestDefinitionSnapshot(
|
||||||
|
definition=updated,
|
||||||
|
rule=rule_record,
|
||||||
|
assignees=assignees,
|
||||||
|
windows=window_records,
|
||||||
|
)
|
||||||
|
|
||||||
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
|
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
|
||||||
"""Raise ValueError unless the rule exists.
|
"""Raise ValueError unless the rule exists.
|
||||||
|
|
||||||
@@ -441,6 +907,81 @@ class QuestDefinitionsDao:
|
|||||||
)
|
)
|
||||||
return [_definition_from_row(row) for row in rows]
|
return [_definition_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
async def _snapshots_for(
|
||||||
|
self, definitions: list[QuestDefinitionRecord]
|
||||||
|
) -> list[QuestDefinitionSnapshot]:
|
||||||
|
"""Read each definition's rule, assignees and windows.
|
||||||
|
|
||||||
|
MUST be called inside the connection lock (and ideally a
|
||||||
|
transaction) so the definitions handed in and the rows this
|
||||||
|
reads back cannot be raced by a concurrent edit between list
|
||||||
|
and bundle. Each rule is read through
|
||||||
|
:class:`ScheduleRulesDao` on the same connection, so the
|
||||||
|
snapshot's rule record is the one the definition references AT
|
||||||
|
the moment of the locked read.
|
||||||
|
"""
|
||||||
|
rules_dao = ScheduleRulesDao(self._database)
|
||||||
|
snapshots: list[QuestDefinitionSnapshot] = []
|
||||||
|
for definition in definitions:
|
||||||
|
rule_record = await rules_dao.get(definition.schedule_rule_id)
|
||||||
|
assert rule_record is not None
|
||||||
|
snapshots.append(
|
||||||
|
QuestDefinitionSnapshot(
|
||||||
|
definition=definition,
|
||||||
|
rule=rule_record,
|
||||||
|
assignees=await self.list_assignees(definition.id),
|
||||||
|
windows=await self.list_windows(definition.id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
|
||||||
|
"""Return every active definition with its rule, assignees and
|
||||||
|
windows, all read inside one locked transaction.
|
||||||
|
|
||||||
|
The definitive list and each definition's linked rows are read
|
||||||
|
in a single BEGIN..COMMIT span under the connection lock, so a
|
||||||
|
concurrent atomic edit cannot interleave between the definition
|
||||||
|
list and the bundle read: the caller receives a coherent
|
||||||
|
snapshot (each definition's rule, assignees and windows as they
|
||||||
|
were at one instant), never a mixed old-rule/new-windows state.
|
||||||
|
"""
|
||||||
|
async with _connection_lock(self._database):
|
||||||
|
async with self._database.transaction():
|
||||||
|
definitions = await self.list_active()
|
||||||
|
return await self._snapshots_for(definitions)
|
||||||
|
|
||||||
|
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
|
||||||
|
"""Return every active definition's snapshot WITHOUT locking.
|
||||||
|
|
||||||
|
Mirrors :meth:`list_snapshots_active` minus the lock and
|
||||||
|
transaction: the definitions and their rules, assignees and
|
||||||
|
windows are read on the caller's connection under whatever
|
||||||
|
transaction the caller is already holding, so they stay coherent
|
||||||
|
with every OTHER table the caller reads inside that same
|
||||||
|
transaction. MUST be called inside the connection lock and an
|
||||||
|
open transaction (the single-transaction input snapshot the
|
||||||
|
materialization walk uses is that caller).
|
||||||
|
"""
|
||||||
|
definitions = await self.list_active()
|
||||||
|
return await self._snapshots_for(definitions)
|
||||||
|
|
||||||
|
async def list_snapshots_by_child(
|
||||||
|
self, child_id: int
|
||||||
|
) -> list[QuestDefinitionSnapshot]:
|
||||||
|
"""Return each definition assigned to ``child_id`` with its rule,
|
||||||
|
assignees and windows, all read inside one locked transaction.
|
||||||
|
|
||||||
|
Mirrors :meth:`list_snapshots_active` but selects by assignment
|
||||||
|
(``list_by_child`` order: newest first). Locks read-and-bundle
|
||||||
|
into one transaction so the bundle never mixes one edit's
|
||||||
|
definition with another's windows.
|
||||||
|
"""
|
||||||
|
async with _connection_lock(self._database):
|
||||||
|
async with self._database.transaction():
|
||||||
|
definitions = await self.list_by_child(child_id)
|
||||||
|
return await self._snapshots_for(definitions)
|
||||||
|
|
||||||
async def update(
|
async def update(
|
||||||
self,
|
self,
|
||||||
definition_id: int,
|
definition_id: int,
|
||||||
@@ -496,14 +1037,18 @@ class QuestDefinitionsDao:
|
|||||||
|
|
||||||
async def add_assignee(
|
async def add_assignee(
|
||||||
self, definition_id: int, child_id: int
|
self, definition_id: int, child_id: int
|
||||||
) -> None:
|
) -> list[ChildRecord]:
|
||||||
"""Assign ``child_id`` to the definition (idempotent).
|
"""Assign ``child_id`` to the definition (idempotent).
|
||||||
|
|
||||||
Validates the assignee inside the same serialized transaction
|
Validates the assignee inside the same serialized transaction
|
||||||
as the INSERT (existing and active AT ASSIGNMENT TIME), so a
|
as the INSERT (existing and active AT ASSIGNMENT TIME), so a
|
||||||
child deactivated concurrently cannot become an assignee.
|
child deactivated concurrently cannot become an assignee.
|
||||||
Assigning an already-assigned child is a no-op, mirroring the
|
Assigning an already-assigned child is a no-op, mirroring the
|
||||||
composite primary key's storage-level guarantee. Changes
|
composite primary key's storage-level guarantee. Returns the
|
||||||
|
definition's assignees read back INSIDE the same transaction
|
||||||
|
that performed the write, so the returned roster is a
|
||||||
|
consistent post-assignment snapshot — a concurrent assign or
|
||||||
|
unassign cannot race it after the write commits. Changes
|
||||||
future instances only: already-generated instances and
|
future instances only: already-generated instances and
|
||||||
completion history are never rewritten here.
|
completion history are never rewritten here.
|
||||||
"""
|
"""
|
||||||
@@ -524,6 +1069,7 @@ class QuestDefinitionsDao:
|
|||||||
"ON CONFLICT (definition_id, child_id) DO NOTHING",
|
"ON CONFLICT (definition_id, child_id) DO NOTHING",
|
||||||
(definition_id, child_id),
|
(definition_id, child_id),
|
||||||
)
|
)
|
||||||
|
return await self.list_assignees(definition_id)
|
||||||
|
|
||||||
async def remove_assignee(
|
async def remove_assignee(
|
||||||
self, definition_id: int, child_id: int
|
self, definition_id: int, child_id: int
|
||||||
@@ -659,3 +1205,46 @@ class QuestDefinitionsDao:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"child {child_id} is inactive and cannot be assigned"
|
f"child {child_id} is inactive and cannot be assigned"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_materialization_input(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
range_start: str,
|
||||||
|
range_end: str,
|
||||||
|
):
|
||||||
|
"""Read every materialization input in ONE locked transaction.
|
||||||
|
|
||||||
|
Returns ``(snapshots, schedules, overrides)``:
|
||||||
|
|
||||||
|
- ``snapshots``: every active definition with its rule, assignees and
|
||||||
|
windows (the :class:`QuestDefinitionSnapshot` shape).
|
||||||
|
- ``schedules`` / ``overrides``: the presence schedules and
|
||||||
|
range-scoped overrides of every assignee child, in
|
||||||
|
:mod:`.dao_presence`'s record shapes.
|
||||||
|
|
||||||
|
The whole read — definitions, rules, assignees, windows, and the
|
||||||
|
assignees' presence schedules and overrides — runs inside a single
|
||||||
|
BEGIN..COMMIT span under the connection lock, so a concurrent edit
|
||||||
|
(definition, assignment, window, or presence) cannot interleave
|
||||||
|
between the parts and the walk never sees a mixed-time view. Each
|
||||||
|
table's SQL stays in its own DAO module: the presence portion is
|
||||||
|
delegated to :mod:`.dao_presence` (a lazy import here, since that
|
||||||
|
module imports this one).
|
||||||
|
"""
|
||||||
|
definitions_dao = QuestDefinitionsDao(database)
|
||||||
|
async with _connection_lock(database):
|
||||||
|
async with database.transaction():
|
||||||
|
snapshots = await definitions_dao.read_snapshots_active()
|
||||||
|
child_ids = sorted(
|
||||||
|
{
|
||||||
|
child.id
|
||||||
|
for snapshot in snapshots
|
||||||
|
for child in snapshot.assignees
|
||||||
|
}
|
||||||
|
)
|
||||||
|
from . import dao_presence # lazy: dao_presence imports us
|
||||||
|
|
||||||
|
schedules, overrides = await dao_presence.read_snapshot_unlocked(
|
||||||
|
database, child_ids, range_start, range_end
|
||||||
|
)
|
||||||
|
return snapshots, schedules, overrides
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""The materialization walk (Feature 07 core).
|
||||||
|
|
||||||
|
:func:`materialize` turns the schedule rules + custody presence of the
|
||||||
|
active quest definitions into concrete ``quest_instances`` rows over a
|
||||||
|
date range. It is the pure walk over definitions x dates x assignees x
|
||||||
|
windows; HA scheduling (day-rollover triggers) is a separate later
|
||||||
|
task and lives elsewhere.
|
||||||
|
|
||||||
|
The walk is deterministic and idempotent:
|
||||||
|
|
||||||
|
- All inputs — the active definitions with their rules, assignees and
|
||||||
|
windows, AND the assignee children's presence schedules and scoped
|
||||||
|
overrides — are read inside ONE locked transaction (see
|
||||||
|
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
|
||||||
|
single coherent snapshot rather than a definition state from one
|
||||||
|
instant and a presence state from another.
|
||||||
|
- Presence is snapshotted into an immutable
|
||||||
|
:class:`~.presence.PresenceEngine` from that same coherent read.
|
||||||
|
- For each date in the range, each definition whose decoded rule
|
||||||
|
:func:`~.recurrence.occurs_on` on that date, each assignee who is
|
||||||
|
``is_present`` on that date and each declared window, one instance is
|
||||||
|
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
|
||||||
|
with a ``generated_at`` stamp shared by the whole batch (one coherent
|
||||||
|
time per run).
|
||||||
|
|
||||||
|
A config change that lands AFTER the snapshot is read but BEFORE a
|
||||||
|
tuple's write (an assignment removed, a definition or child deactivated,
|
||||||
|
a window removed) makes that tuple SKIP rather than abort the batch:
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
|
||||||
|
every materialization precondition (active definition, active child,
|
||||||
|
live assignment, declared window, and its CURRENT ``due_time``) inside
|
||||||
|
the SAME transaction as the INSERT, so the check and the write cannot be
|
||||||
|
split by a racing config edit.
|
||||||
|
|
||||||
|
The upsert is idempotent on (definition_id, child_id, due_date,
|
||||||
|
window), so re-running the materialization over the same range never
|
||||||
|
duplicates a row; a tuple whose instance is already completed is SKIPPED
|
||||||
|
(never rewritten, never raised) by
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
|
||||||
|
|
||||||
|
Regeneration: a config or presence change must rebuild future instances
|
||||||
|
so they track the new state. :func:`regenerate_for_definition` covers
|
||||||
|
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
|
||||||
|
covers a single child's presence change (Feature 09). Both delete the
|
||||||
|
affected open instances at or after today — never a completed instance,
|
||||||
|
never the past — and re-run the walk over the rolling horizon
|
||||||
|
``[today, today + DEFAULT_HORIZON_DAYS]``.
|
||||||
|
|
||||||
|
HOOK (Feature 09 presence services): the presence business layer that
|
||||||
|
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
|
||||||
|
presence-state write so a child's future instances track its presence:
|
||||||
|
``set_presence_pattern`` (including clearing/setting an empty pattern),
|
||||||
|
deleting/clearing the presence schedule
|
||||||
|
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||||
|
``create_presence_override``, and ``delete_presence_override``. A
|
||||||
|
schedule delete changes presence state too (the child becomes
|
||||||
|
always-present), so it must regenerate just like the others. This hook
|
||||||
|
is documented, not yet wired: no presence business layer exists yet, and
|
||||||
|
the presence DAO (``dao_presence.py``) must stay free of business rules
|
||||||
|
— never call :func:`regenerate_for_child` from inside the DAO layer.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from .const import DEFAULT_HORIZON_DAYS
|
||||||
|
from .dao_instances import QuestInstancesDao, _today
|
||||||
|
from .dao_rules import (
|
||||||
|
_validate_date,
|
||||||
|
load_materialization_input,
|
||||||
|
schedule_rule_from_storage,
|
||||||
|
schedule_rule_storage_from_record,
|
||||||
|
)
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
|
||||||
|
from .recurrence import ScheduleRule, occurs_on
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp() -> str:
|
||||||
|
"""Return the batch's shared UTC second-precision generation stamp."""
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_rule(snapshot) -> ScheduleRule:
|
||||||
|
"""Decode a snapshot's rule record back into a ScheduleRule.
|
||||||
|
|
||||||
|
The record carried by the snapshot is the one the definition
|
||||||
|
referenced at read time, so decoding it here (rather than re-reading
|
||||||
|
through ``ScheduleRulesDao``) preserves the coherence the snapshot
|
||||||
|
guarantees.
|
||||||
|
"""
|
||||||
|
return schedule_rule_from_storage(
|
||||||
|
schedule_rule_storage_from_record(snapshot.rule)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_engine(
|
||||||
|
schedules_records,
|
||||||
|
overrides_records,
|
||||||
|
) -> PresenceEngine:
|
||||||
|
"""Convert raw presence records into an immutable PresenceEngine."""
|
||||||
|
schedules: dict[int, PresenceSchedule] = {}
|
||||||
|
for child_id, record in schedules_records.items():
|
||||||
|
schedules[child_id] = PresenceSchedule.decode(
|
||||||
|
child_id, record.anchor_date, record.pattern
|
||||||
|
)
|
||||||
|
overrides: dict[int, list[PresenceOverride]] = {}
|
||||||
|
for child_id, records in overrides_records.items():
|
||||||
|
overrides[child_id] = [
|
||||||
|
PresenceOverride(
|
||||||
|
record.child_id,
|
||||||
|
record.start_date,
|
||||||
|
record.end_date,
|
||||||
|
record.is_present,
|
||||||
|
record.note,
|
||||||
|
)
|
||||||
|
for record in records
|
||||||
|
]
|
||||||
|
return PresenceEngine(schedules, overrides)
|
||||||
|
|
||||||
|
|
||||||
|
async def materialize(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
*,
|
||||||
|
child_ids: list[int] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Generate quest instances for the closed range [start_date, end_date].
|
||||||
|
|
||||||
|
``start_date`` and ``end_date`` must be strict ISO calendar dates
|
||||||
|
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
|
||||||
|
else raises ValueError naming the offending field. Returns the number
|
||||||
|
of instances upserted for the range (idempotent — a re-run refreshes
|
||||||
|
the same rows, never duplicates them).
|
||||||
|
|
||||||
|
``child_ids`` optionally scopes the walk to exactly those children:
|
||||||
|
assignees outside the list are never written, so a caller that only
|
||||||
|
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
|
||||||
|
after a presence change) cannot touch any unrelated child's rows.
|
||||||
|
When omitted (or None) the walk covers every assignee as before.
|
||||||
|
|
||||||
|
All inputs are read in ONE locked transaction; each tuple's write then
|
||||||
|
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
|
||||||
|
which re-validates the tuple and its current ``due_time`` in the same
|
||||||
|
transaction as the INSERT, so a config change that arrives mid-batch
|
||||||
|
skips the affected tuples rather than aborting the batch or leaving a
|
||||||
|
partial result.
|
||||||
|
"""
|
||||||
|
_validate_date(start_date, "start_date")
|
||||||
|
_validate_date(end_date, "end_date")
|
||||||
|
if end_date < start_date:
|
||||||
|
raise ValueError(
|
||||||
|
f"end_date must be on or after start_date, got "
|
||||||
|
f"{end_date!r} < {start_date!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshots, schedules_records, overrides_records = await (
|
||||||
|
load_materialization_input(database, start_date, end_date)
|
||||||
|
)
|
||||||
|
engine = _build_engine(schedules_records, overrides_records)
|
||||||
|
child_filter = None if child_ids is None else set(child_ids)
|
||||||
|
|
||||||
|
definitions = [
|
||||||
|
(snapshot.definition.id, _decode_rule(snapshot),
|
||||||
|
snapshot.assignees, snapshot.windows)
|
||||||
|
for snapshot in snapshots
|
||||||
|
]
|
||||||
|
|
||||||
|
generated_at = _now_stamp()
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
|
||||||
|
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||||
|
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
cursor = start
|
||||||
|
while cursor <= end:
|
||||||
|
iso = cursor.isoformat()
|
||||||
|
for definition_id, rule, assignees, windows in definitions:
|
||||||
|
if not occurs_on(rule, cursor):
|
||||||
|
continue
|
||||||
|
for child in assignees:
|
||||||
|
if child_filter is not None and child.id not in child_filter:
|
||||||
|
continue
|
||||||
|
if not engine.is_present(child.id, cursor):
|
||||||
|
continue
|
||||||
|
for window in windows:
|
||||||
|
written = await instances.upsert_if_valid(
|
||||||
|
definition_id,
|
||||||
|
child.id,
|
||||||
|
iso,
|
||||||
|
generated_at,
|
||||||
|
window=window.window,
|
||||||
|
due_time=window.due_time,
|
||||||
|
)
|
||||||
|
if written is not None:
|
||||||
|
count += 1
|
||||||
|
cursor += datetime.timedelta(days=1)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
async def regenerate_for_definition(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
definition_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""Regenerate a definition's future instances after a config change.
|
||||||
|
|
||||||
|
The Feature 06 edit/assignment/activation paths call this after a
|
||||||
|
successful write so future instances track the new state: the
|
||||||
|
definition's open instances at or after today are deleted (never a
|
||||||
|
completed instance, never the past) via
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
|
||||||
|
then the materialization walk re-runs over the rolling horizon
|
||||||
|
``[today, today + DEFAULT_HORIZON_DAYS]`` so the (changed) rule,
|
||||||
|
assignee set and windows re-materialize against today's state.
|
||||||
|
|
||||||
|
"today" is computed the same way the walk computes it —
|
||||||
|
:func:`~.dao_instances._today` — so the delete cutoff and the
|
||||||
|
re-materialization range share one anchor, and the walk's
|
||||||
|
``upsert_if_valid`` no-past guard never rejects the regenerated
|
||||||
|
range. Returns the number of instances the re-materialization
|
||||||
|
upserted across the whole snapshot (idempotent — other active
|
||||||
|
definitions' tuples refresh in place, never duplicate).
|
||||||
|
"""
|
||||||
|
today = _today()
|
||||||
|
start_date = today.isoformat()
|
||||||
|
end_date = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
|
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
await instances.delete_future_uncompleted(definition_id, start_date)
|
||||||
|
return await materialize(database, start_date, end_date)
|
||||||
|
|
||||||
|
|
||||||
|
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
|
||||||
|
# child-presence counterpart to ``regenerate_for_definition``. The
|
||||||
|
# presence business layer that lands in Feature 09 MUST call
|
||||||
|
# ``regenerate_for_child`` after EVERY presence-state write, so future
|
||||||
|
# instances track a child's changed presence: set_presence_pattern
|
||||||
|
# (including clearing/setting an empty pattern), deleting/clearing the
|
||||||
|
# presence schedule (PresenceSchedulesDao.delete),
|
||||||
|
# create_presence_override, and delete_presence_override. A schedule
|
||||||
|
# delete changes presence state too (the child becomes always-present),
|
||||||
|
# so it must regenerate just like the others. It is deliberately NOT
|
||||||
|
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
|
||||||
|
# business rules), and no presence business layer exists yet to host the
|
||||||
|
# call — see the module docstring note above.
|
||||||
|
async def regenerate_for_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""Regenerate a child's future instances after a presence change.
|
||||||
|
|
||||||
|
The Feature 09 presence services call this after every
|
||||||
|
presence-state write — set_presence_pattern (including clearing or
|
||||||
|
setting an empty pattern), deleting/clearing the presence schedule
|
||||||
|
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||||
|
create_presence_override, and delete_presence_override — so a
|
||||||
|
child's future instances track its new presence: the child's open
|
||||||
|
instances at or after today are deleted across ALL its definitions
|
||||||
|
(never a completed instance, never the past) via
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
||||||
|
then the materialization walk re-runs over the rolling horizon
|
||||||
|
``[today, today + DEFAULT_HORIZON_DAYS]`` scoped to that child only,
|
||||||
|
so the child's now-absent/present dates re-materialize against
|
||||||
|
today's presence WITHOUT touching any other child's rows.
|
||||||
|
|
||||||
|
``child_id`` must be a plain int — bool and float are rejected
|
||||||
|
(SQLite binds a bool as 0/1 and a float would round, so a malformed
|
||||||
|
id must never mutate the wrong profile).
|
||||||
|
|
||||||
|
"today" is computed the same way the walk computes it —
|
||||||
|
:func:`~.dao_instances._today` — and the re-materialization range uses the
|
||||||
|
delete's returned effective cutoff (see
|
||||||
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
|
||||||
|
so the delete cutoff and the re-materialization window share ONE
|
||||||
|
execution-day anchor even when the call is queued across midnight.
|
||||||
|
Returns the number of instances the re-materialization upserted for
|
||||||
|
the child across the whole snapshot.
|
||||||
|
"""
|
||||||
|
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||||
|
|
||||||
|
today = _today()
|
||||||
|
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
# Use the delete's returned effective cutoff as the materialize anchor,
|
||||||
|
# NOT the independently-computed "today" above. If the call is queued
|
||||||
|
# across midnight the delete clamps its cutoff to the execution-day;
|
||||||
|
# materializing from the stale "today" (now yesterday) would trip the
|
||||||
|
# walk's no-past guard and leave the child's future instances deleted
|
||||||
|
# but not rebuilt. One anchor for both keeps them consistent.
|
||||||
|
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
|
||||||
|
child_id, today.isoformat()
|
||||||
|
)
|
||||||
|
start = datetime.date.fromisoformat(effective_cutoff)
|
||||||
|
end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
|
return await materialize(
|
||||||
|
database, start.isoformat(), end_date, child_ids=[child_id]
|
||||||
|
)
|
||||||
@@ -25,11 +25,12 @@ pre-D-008 databases to the multi-assignee model: it creates
|
|||||||
``quest_definition_assignees`` and, when ``quest_definitions`` still
|
``quest_definition_assignees`` and, when ``quest_definitions`` still
|
||||||
carries a single ``child_id`` column, rebuilds the table without it,
|
carries a single ``child_id`` column, rebuilds the table without it,
|
||||||
copying each definition's assignee across. Migration 4 adds the
|
copying each definition's assignee across. Migration 4 adds the
|
||||||
windows table and migration 5 rebuilds ``quest_instances`` onto the
|
windows table, migration 5 rebuilds ``quest_instances`` onto the
|
||||||
widened (definition_id, child_id, due_date, window) key. Each callable
|
widened (definition_id, child_id, due_date, window) key, and migration
|
||||||
migration runs inside its own transaction together with its version
|
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
|
||||||
stamp, so a crash mid-step rolls the statements and the stamp back
|
column. Each callable migration runs inside its own transaction
|
||||||
together.
|
together with its version stamp, so a crash mid-step rolls the
|
||||||
|
statements and the stamp back together.
|
||||||
|
|
||||||
Migration entries are either an ordered sequence of SQL statements or
|
Migration entries are either an ordered sequence of SQL statements or
|
||||||
an async callable taking ``(database, target_version)``. Callables
|
an async callable taking ``(database, target_version)``. Callables
|
||||||
@@ -73,6 +74,7 @@ from typing import Union
|
|||||||
|
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
from .schema import (
|
from .schema import (
|
||||||
|
COMPLETION_EVENTS_TABLE_SQL,
|
||||||
QUEST_DEFINITIONS_TABLE_SQL,
|
QUEST_DEFINITIONS_TABLE_SQL,
|
||||||
QUEST_INSTANCES_TABLE_SQL,
|
QUEST_INSTANCES_TABLE_SQL,
|
||||||
SCHEMA_V1_STATEMENTS,
|
SCHEMA_V1_STATEMENTS,
|
||||||
@@ -326,6 +328,74 @@ async def _widen_instance_key(
|
|||||||
await database.execute("PRAGMA foreign_keys = ON")
|
await database.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_actor_child_id(
|
||||||
|
database: NestQuestDatabase, target_version: int
|
||||||
|
) -> None:
|
||||||
|
"""Bring ``completion_events`` to the D-008 actor-child shape.
|
||||||
|
|
||||||
|
The ``actor_child_id`` column records which panel profile was
|
||||||
|
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
|
||||||
|
because the canonical shape also carries an actor-pair coherence
|
||||||
|
CHECK (panel events require it, user events forbid it), and CHECK
|
||||||
|
constraints cannot be added to an existing table. Databases with
|
||||||
|
the legacy shape are therefore rebuilt via SQLite's canonical
|
||||||
|
procedure, backfilling each legacy panel row's ``actor_child_id``
|
||||||
|
to ``child_id`` — pre-D-008 panel completions were always recorded
|
||||||
|
against the tapped child themselves — while admin rows stay NULL.
|
||||||
|
Row ids are preserved so ``quest_instances`` references and the
|
||||||
|
append-only history stay intact. Fresh databases already match
|
||||||
|
the canonical shape and only stamp.
|
||||||
|
|
||||||
|
The rebuild runs with ``foreign_keys = OFF`` and verifies
|
||||||
|
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||||
|
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
|
||||||
|
completion_events table keeps its append-only guarantee.
|
||||||
|
"""
|
||||||
|
legacy_shape = not await _table_has_column(
|
||||||
|
database, "completion_events", "actor_child_id"
|
||||||
|
)
|
||||||
|
await database.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
try:
|
||||||
|
async with database.transaction():
|
||||||
|
if legacy_shape:
|
||||||
|
await database.execute(
|
||||||
|
"CREATE TABLE completion_events_rebuilt "
|
||||||
|
f"{COMPLETION_EVENTS_TABLE_SQL}"
|
||||||
|
)
|
||||||
|
await database.execute(
|
||||||
|
"INSERT INTO completion_events_rebuilt "
|
||||||
|
"(id, instance_id, child_id, event_type, actor_source, "
|
||||||
|
"actor_user_id, actor_child_id, occurred_at, "
|
||||||
|
"was_on_time) "
|
||||||
|
"SELECT id, instance_id, child_id, event_type, "
|
||||||
|
"actor_source, actor_user_id, "
|
||||||
|
"CASE WHEN actor_source = 'panel' THEN child_id "
|
||||||
|
"ELSE NULL END, "
|
||||||
|
"occurred_at, was_on_time "
|
||||||
|
"FROM completion_events ORDER BY id"
|
||||||
|
)
|
||||||
|
await database.execute("DROP TABLE completion_events")
|
||||||
|
await database.execute(
|
||||||
|
"ALTER TABLE completion_events_rebuilt "
|
||||||
|
"RENAME TO completion_events"
|
||||||
|
)
|
||||||
|
violations = await database.fetch_all(
|
||||||
|
"PRAGMA foreign_key_check"
|
||||||
|
)
|
||||||
|
if violations:
|
||||||
|
raise RuntimeError(
|
||||||
|
"completion_events rebuild produced foreign-key "
|
||||||
|
f"violations: {violations!r}"
|
||||||
|
)
|
||||||
|
LOGGER.info(
|
||||||
|
"Added actor_child_id to completion_events; legacy "
|
||||||
|
"panel rows backfilled to their own child_id"
|
||||||
|
)
|
||||||
|
await stamp_schema_version(database, target_version)
|
||||||
|
finally:
|
||||||
|
await database.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
|
||||||
#: The ordered migration list. Append-only: never edit an applied
|
#: The ordered migration list. Append-only: never edit an applied
|
||||||
#: entry, add the next one instead. (Migration 1's content was
|
#: entry, add the next one instead. (Migration 1's content was
|
||||||
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
||||||
@@ -340,6 +410,10 @@ MIGRATIONS: Sequence[MigrationStep] = [
|
|||||||
# Migration 5 (D-008): rebuild quest_instances onto the widened
|
# Migration 5 (D-008): rebuild quest_instances onto the widened
|
||||||
# (definition_id, child_id, due_date, window) key.
|
# (definition_id, child_id, due_date, window) key.
|
||||||
_widen_instance_key,
|
_widen_instance_key,
|
||||||
|
# Migration 6 (D-008): rebuild completion_events with the
|
||||||
|
# actor_child_id column and its actor-pair coherence CHECK,
|
||||||
|
# backfilling legacy panel rows to their own child_id.
|
||||||
|
_add_actor_child_id,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ from typing import Any
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
CONF_ADMIN_USER_IDS,
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
CONF_HORIZON_DAYS,
|
CONF_HORIZON_DAYS,
|
||||||
CONF_PANEL_IDLE_TIMEOUT,
|
CONF_PANEL_IDLE_TIMEOUT,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
DEFAULT_DAY_ROLLOVER_TIME,
|
||||||
DEFAULT_HORIZON_DAYS,
|
DEFAULT_HORIZON_DAYS,
|
||||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||||
|
DOMAIN,
|
||||||
)
|
)
|
||||||
|
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||||
|
|
||||||
MIN_HORIZON_DAYS = 1
|
MIN_HORIZON_DAYS = 1
|
||||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
MIN_PANEL_IDLE_TIMEOUT = 30
|
||||||
@@ -55,51 +59,168 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def _build_schema(current: dict[str, Any]) -> vol.Schema:
|
def _build_schema(
|
||||||
"""Build the form schema, pre-filled with the current values."""
|
current: dict[str, Any],
|
||||||
return vol.Schema(
|
*,
|
||||||
{
|
admin_choices: dict[str, str] | None = None,
|
||||||
vol.Required(
|
admin_default: list[str] | None = None,
|
||||||
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
|
) -> vol.Schema:
|
||||||
): int,
|
"""Build the form schema, pre-filled with the current values.
|
||||||
vol.Required(
|
|
||||||
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
|
The admin picker is a multi-select of EXISTING Home Assistant users
|
||||||
): str,
|
(never a free-text field — the allowlist stores user IDs); its
|
||||||
vol.Required(
|
default is the current database allowlist, so an untouched submit
|
||||||
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
re-saves exactly who is already allowed.
|
||||||
): int,
|
"""
|
||||||
}
|
fields: dict[Any, Any] = {
|
||||||
)
|
vol.Required(
|
||||||
|
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
|
||||||
|
): int,
|
||||||
|
vol.Required(
|
||||||
|
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
|
||||||
|
): str,
|
||||||
|
vol.Required(
|
||||||
|
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
||||||
|
): int,
|
||||||
|
}
|
||||||
|
if admin_choices is not None:
|
||||||
|
# HA's supported multi-select validator: the frontend can
|
||||||
|
# serialize the field as a real picker of the given choices.
|
||||||
|
fields[
|
||||||
|
vol.Optional(
|
||||||
|
CONF_ADMIN_USER_IDS, default=list(admin_default or [])
|
||||||
|
)
|
||||||
|
] = cv.multi_select(admin_choices)
|
||||||
|
return vol.Schema(fields)
|
||||||
|
|
||||||
|
|
||||||
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||||
"""Handle the options flow for NestQuest."""
|
"""Handle the options flow for NestQuest."""
|
||||||
|
|
||||||
|
def _async_database(self):
|
||||||
|
"""Return the runtime database for this entry, or None.
|
||||||
|
|
||||||
|
Real HA only opens the options flow for an existing entry, so
|
||||||
|
the runtime record (and its database) exists; the None branch
|
||||||
|
covers direct construction in tests and degraded states.
|
||||||
|
"""
|
||||||
|
hass_data = getattr(self.hass, "data", None)
|
||||||
|
if not isinstance(hass_data, dict):
|
||||||
|
return None
|
||||||
|
record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id)
|
||||||
|
database = getattr(record, "database", None)
|
||||||
|
return database if getattr(database, "connected", False) else None
|
||||||
|
|
||||||
|
async def _async_admin_choices(self) -> dict[str, str]:
|
||||||
|
"""Map HA user id -> display name for the picker.
|
||||||
|
|
||||||
|
Real Home Assistant always provides ``hass.auth``; a missing
|
||||||
|
one (direct construction in tests, degraded harness) degrades
|
||||||
|
to an empty picker rather than crashing the form.
|
||||||
|
"""
|
||||||
|
auth = getattr(self.hass, "auth", None)
|
||||||
|
if auth is None:
|
||||||
|
return {}
|
||||||
|
users = await auth.async_get_users()
|
||||||
|
return {
|
||||||
|
user.id: user.name
|
||||||
|
for user in users
|
||||||
|
if getattr(user, "id", None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _async_current_admin_ids(self) -> list[str]:
|
||||||
|
"""Return the current allowlist, or [] without a database."""
|
||||||
|
database = self._async_database()
|
||||||
|
if database is None:
|
||||||
|
return []
|
||||||
|
return await list_admin_ids(database)
|
||||||
|
|
||||||
async def async_step_init(
|
async def async_step_init(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Manage the NestQuest options."""
|
"""Manage the NestQuest options."""
|
||||||
|
admin_choices = await self._async_admin_choices()
|
||||||
|
admin_default = await self._async_current_admin_ids()
|
||||||
if user_input is None:
|
if user_input is None:
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init", data_schema=_build_schema(self._current_values())
|
step_id="init",
|
||||||
|
data_schema=_build_schema(
|
||||||
|
self._current_values(),
|
||||||
|
admin_choices=admin_choices,
|
||||||
|
admin_default=admin_default,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
errors = _async_validate(user_input)
|
errors = _async_validate(user_input)
|
||||||
|
# The admin ids are validated only when the key is present:
|
||||||
|
# schema-validated submits always carry it (vol.Optional's
|
||||||
|
# default fills from the current allowlist), while raw callers
|
||||||
|
# omitting the key keep the field untouched.
|
||||||
|
admin_ids_present = CONF_ADMIN_USER_IDS in user_input
|
||||||
|
admin_ids = user_input.get(CONF_ADMIN_USER_IDS)
|
||||||
if errors:
|
if errors:
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=_build_schema(self._current_values()),
|
data_schema=_build_schema(
|
||||||
|
self._current_values(),
|
||||||
|
admin_choices=admin_choices,
|
||||||
|
admin_default=admin_default,
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
if admin_ids_present and (
|
||||||
|
not isinstance(admin_ids, list)
|
||||||
|
or any(not isinstance(value, str) for value in admin_ids)
|
||||||
|
):
|
||||||
|
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
||||||
|
elif admin_ids_present and not admin_ids:
|
||||||
|
# An empty allowlist fails closed: saving zero admins is
|
||||||
|
# refused, never silently applied.
|
||||||
|
errors[CONF_ADMIN_USER_IDS] = "no_admins"
|
||||||
|
elif admin_ids_present and any(
|
||||||
|
value not in admin_choices for value in admin_ids
|
||||||
|
):
|
||||||
|
# The schema enforces membership for real HA submits; this
|
||||||
|
# backs the raw-input path up so an unknown id can never
|
||||||
|
# reach the allowlist.
|
||||||
|
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
||||||
|
elif admin_ids_present and len(set(admin_ids)) != len(admin_ids):
|
||||||
|
# set_admin_ids refuses duplicates with ValueError; a flow
|
||||||
|
# submission must surface that as a field error instead of
|
||||||
|
# an unhandled crash.
|
||||||
|
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
|
||||||
|
if errors:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="init",
|
||||||
|
data_schema=_build_schema(
|
||||||
|
self._current_values(),
|
||||||
|
admin_choices=admin_choices,
|
||||||
|
admin_default=admin_default,
|
||||||
|
),
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_create_entry(
|
# Apply the picker's selection to the allowlist when the
|
||||||
title="NestQuest",
|
# runtime database is reachable; the selection is always
|
||||||
data={
|
# stored on the entry as the disaster-recovery copy.
|
||||||
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
|
database = self._async_database()
|
||||||
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
|
if database is not None and admin_ids_present:
|
||||||
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
|
await set_admin_ids(database, admin_ids)
|
||||||
},
|
data = {
|
||||||
)
|
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
|
||||||
|
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
|
||||||
|
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
|
||||||
|
}
|
||||||
|
if admin_ids_present:
|
||||||
|
data[CONF_ADMIN_USER_IDS] = admin_ids
|
||||||
|
elif admin_default:
|
||||||
|
# HA replaces entry.options wholesale, so an omitted picker
|
||||||
|
# field must carry the CURRENT allowlist forward: dropping
|
||||||
|
# it would leave only the stale config-flow copy in entry
|
||||||
|
# data, and a later database loss would resurrect admins
|
||||||
|
# the owner has since removed.
|
||||||
|
data[CONF_ADMIN_USER_IDS] = list(admin_default)
|
||||||
|
return self.async_create_entry(title="NestQuest", data=data)
|
||||||
|
|
||||||
def _current_values(self) -> dict[str, Any]:
|
def _current_values(self) -> dict[str, Any]:
|
||||||
"""Resolve current values from entry options, then entry data, then defaults."""
|
"""Resolve current values from entry options, then entry data, then defaults."""
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
"""The presence and custody model (Feature 05).
|
||||||
|
|
||||||
|
PURE module: no Home Assistant imports, no database access, no
|
||||||
|
timezone logic — it operates on plain calendar dates only (feature
|
||||||
|
guardrail). The later engine (cycle position, ``is_present``,
|
||||||
|
overrides, previews) builds directly on this model.
|
||||||
|
|
||||||
|
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
|
||||||
|
week is this child at this house?" for an N-week repeating custody
|
||||||
|
pattern. Two of the household's three children follow a two-week
|
||||||
|
rotation; a child with NO schedule row is present every day (that
|
||||||
|
default lives in the engine, not here).
|
||||||
|
|
||||||
|
Pattern encoding: the model's canonical serialization is the SAME
|
||||||
|
pipe-separated CSV the database stores (see schema.py's
|
||||||
|
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
|
||||||
|
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
|
||||||
|
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
|
||||||
|
empty segment means absent every day of that week. Encoding sorts
|
||||||
|
each segment's weekdays and uses the exact segment order, so
|
||||||
|
``decode(encode(schedule))`` round-trips losslessly and two schedules
|
||||||
|
with equal patterns produce equal strings.
|
||||||
|
|
||||||
|
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
|
||||||
|
computed from anchor-date arithmetic ONLY — never ISO week numbers or
|
||||||
|
week parity (D-004): 53-week years silently invert parity on January
|
||||||
|
1st. The arithmetic lives in the engine task; this module validates
|
||||||
|
that the anchor is a strict calendar date.
|
||||||
|
|
||||||
|
Validation policy: construction is total — every reject happens in
|
||||||
|
``__init__`` so no invalid schedule can ever exist:
|
||||||
|
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
|
||||||
|
the database CHECK (schema.py) and keeps anchor arithmetic and the
|
||||||
|
encoded pattern human-readable; revisit both together if a longer
|
||||||
|
cycle is ever needed.
|
||||||
|
- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly
|
||||||
|
(no gaps, no extra weeks) with sets of integers 0..6 (bools
|
||||||
|
rejected: True would silently mean Monday).
|
||||||
|
- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a
|
||||||
|
``datetime.date``).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import MappingProxyType
|
||||||
|
|
||||||
|
#: The schema's documented cap for cycle_length_weeks (schema.py keeps
|
||||||
|
#: the pattern human-readable and the anchor arithmetic small).
|
||||||
|
MAX_CYCLE_LENGTH_WEEKS = 4
|
||||||
|
|
||||||
|
_DATE_FORMAT = "%Y-%m-%d"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: object, field_name: str) -> datetime.date:
|
||||||
|
"""Accept a datetime.date or a strict YYYY-MM-DD string.
|
||||||
|
|
||||||
|
``datetime.datetime`` is rejected explicitly: it SUBCLASSES
|
||||||
|
datetime.date, so a bare isinstance check would store a value with
|
||||||
|
a time component and every later calendar-date comparison (e.g.
|
||||||
|
``PresenceOverride.covers``) would raise TypeError.
|
||||||
|
"""
|
||||||
|
if isinstance(value, datetime.datetime):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field_name} must be a plain calendar date, not a "
|
||||||
|
f"datetime with a time component: {value!r}"
|
||||||
|
)
|
||||||
|
if isinstance(value, datetime.date):
|
||||||
|
return value
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field_name} must be a datetime.date or an ISO date string, "
|
||||||
|
f"got {value!r}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||||
|
) from None
|
||||||
|
if parsed.isoformat() != value:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_week_index(week: object, cycle_length_weeks: int) -> int:
|
||||||
|
"""Reject non-int week indices (bools included) and out-of-cycle."""
|
||||||
|
if isinstance(week, bool) or not isinstance(week, int):
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern week index must be an integer, got {week!r}"
|
||||||
|
)
|
||||||
|
if not 0 <= week < cycle_length_weeks:
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern week index must be in 0..{cycle_length_weeks - 1}, "
|
||||||
|
f"got {week}"
|
||||||
|
)
|
||||||
|
return week
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
|
||||||
|
"""Validate one week's present-weekday set."""
|
||||||
|
if isinstance(weekdays, (str, bytes)) or weekdays is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
|
||||||
|
f"got {weekdays!r}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
members = list(weekdays)
|
||||||
|
except TypeError:
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
|
||||||
|
f"got {weekdays!r}"
|
||||||
|
) from None
|
||||||
|
for member in members:
|
||||||
|
if isinstance(member, bool) or not isinstance(member, int):
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern[{week}] weekday must be an int 0..6, got {member!r}"
|
||||||
|
)
|
||||||
|
if not 0 <= member <= 6:
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern[{week}] weekday must be in 0..6 (Monday=0), "
|
||||||
|
f"got {member}"
|
||||||
|
)
|
||||||
|
return frozenset(members)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_cycle_length(value: object) -> int:
|
||||||
|
"""Reject non-int and out-of-range cycle lengths (bools included)."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(
|
||||||
|
f"cycle_length_weeks must be an integer, got {value!r}"
|
||||||
|
)
|
||||||
|
if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS:
|
||||||
|
raise ValueError(
|
||||||
|
f"cycle_length_weeks must be between 1 and "
|
||||||
|
f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_child_id(value: object) -> int:
|
||||||
|
"""Reject non-int child ids (bools would silently address child 1)."""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, init=False)
|
||||||
|
class PresenceSchedule:
|
||||||
|
"""One child's N-week repeating presence pattern.
|
||||||
|
|
||||||
|
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
|
||||||
|
to the set of present weekdays (ints, Monday=0). A week with an
|
||||||
|
empty set means the child is absent every day of that week — which
|
||||||
|
is distinct from having no schedule at all (the engine treats a
|
||||||
|
missing schedule as present every day).
|
||||||
|
"""
|
||||||
|
|
||||||
|
child_id: int
|
||||||
|
cycle_length_weeks: int
|
||||||
|
anchor_date: datetime.date
|
||||||
|
pattern: MappingProxyType # week index -> frozenset of weekdays
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
child_id: int,
|
||||||
|
cycle_length_weeks: int,
|
||||||
|
anchor_date: object,
|
||||||
|
pattern: dict[int, object],
|
||||||
|
) -> None:
|
||||||
|
object.__setattr__(self, "child_id", _validate_child_id(child_id))
|
||||||
|
cycle = _validate_cycle_length(cycle_length_weeks)
|
||||||
|
object.__setattr__(self, "cycle_length_weeks", cycle)
|
||||||
|
object.__setattr__(
|
||||||
|
self, "anchor_date", _parse_date(anchor_date, "anchor_date")
|
||||||
|
)
|
||||||
|
if not isinstance(pattern, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"pattern must map week index to a set of present "
|
||||||
|
f"weekdays, got {pattern!r}"
|
||||||
|
)
|
||||||
|
normalised: dict[int, frozenset[int]] = {}
|
||||||
|
for week, weekdays in pattern.items():
|
||||||
|
index = _validate_week_index(week, cycle)
|
||||||
|
normalised[index] = _validate_weekday_set(index, weekdays)
|
||||||
|
missing = [
|
||||||
|
week for week in range(cycle) if week not in normalised
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"pattern must cover week indices 0..{cycle - 1}; "
|
||||||
|
f"missing {missing}"
|
||||||
|
)
|
||||||
|
# Frozen must mean frozen: a bare dict would let callers inject
|
||||||
|
# unvalidated weekdays (or drop required weeks) AFTER
|
||||||
|
# construction, bypassing total validation. The mapping
|
||||||
|
# proxy exposes the validated contents read-only; the
|
||||||
|
# per-week sets are already frozensets.
|
||||||
|
object.__setattr__(self, "pattern", MappingProxyType(normalised))
|
||||||
|
|
||||||
|
def encode(self) -> str:
|
||||||
|
"""Serialize to the database's pipe-separated CSV shape.
|
||||||
|
|
||||||
|
Segments appear in week order; each segment is the week's
|
||||||
|
present weekdays as a sorted comma-separated list (an empty set
|
||||||
|
encodes as an empty segment). Two schedules with equal
|
||||||
|
patterns encode identically.
|
||||||
|
"""
|
||||||
|
segments = [
|
||||||
|
",".join(str(day) for day in sorted(self.pattern[week]))
|
||||||
|
for week in range(self.cycle_length_weeks)
|
||||||
|
]
|
||||||
|
return "|".join(segments)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def decode(
|
||||||
|
cls,
|
||||||
|
child_id: int,
|
||||||
|
anchor_date: object,
|
||||||
|
encoded: str,
|
||||||
|
) -> "PresenceSchedule":
|
||||||
|
"""Build a schedule from its encoded pattern.
|
||||||
|
|
||||||
|
The cycle length is derived from the segment count, mirroring
|
||||||
|
the database's segment-count CHECK. Decoding is strict: wrong
|
||||||
|
shapes, non-numeric or out-of-range weekdays are rejected —
|
||||||
|
the database CHECKs make such rows unrepresentable, and this
|
||||||
|
is the same contract on the model side. A segment count above
|
||||||
|
the documented cycle cap is rejected by the ordinary
|
||||||
|
construction validation.
|
||||||
|
"""
|
||||||
|
if not isinstance(encoded, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"encoded pattern must be a string, got {encoded!r}"
|
||||||
|
)
|
||||||
|
segments = encoded.split("|")
|
||||||
|
pattern: dict[int, set[int]] = {}
|
||||||
|
for week, segment in enumerate(segments):
|
||||||
|
weekdays: set[int] = set()
|
||||||
|
if segment:
|
||||||
|
for part in segment.split(","):
|
||||||
|
if len(part) != 1 or part not in "0123456":
|
||||||
|
raise ValueError(
|
||||||
|
f"encoded segment {week} carries an invalid "
|
||||||
|
f"weekday {part!r} (single digits 0-6 only)"
|
||||||
|
)
|
||||||
|
weekdays.add(int(part))
|
||||||
|
pattern[week] = weekdays
|
||||||
|
return cls(child_id, len(segments), anchor_date, pattern)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, init=False)
|
||||||
|
class PresenceOverride:
|
||||||
|
"""A date-specific present/absent flag beating any pattern.
|
||||||
|
|
||||||
|
Covers a single date (``end_date == start_date``) or an inclusive
|
||||||
|
range. A concrete range is the storage shape
|
||||||
|
(``presence_overrides.end_date`` is NOT NULL), so the model
|
||||||
|
requires both bounds and validates start <= end. Construction and
|
||||||
|
validation live here so the engine can trust every instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
child_id: int
|
||||||
|
start_date: datetime.date
|
||||||
|
end_date: datetime.date
|
||||||
|
is_present: bool
|
||||||
|
note: str | None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
child_id: int,
|
||||||
|
start_date: object,
|
||||||
|
end_date: object,
|
||||||
|
is_present: bool,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
object.__setattr__(self, "child_id", _validate_child_id(child_id))
|
||||||
|
start = _parse_date(start_date, "start_date")
|
||||||
|
end = _parse_date(end_date, "end_date")
|
||||||
|
if end < start:
|
||||||
|
raise ValueError(
|
||||||
|
f"end_date must be on or after start_date, got "
|
||||||
|
f"{end.isoformat()} < {start.isoformat()}"
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "start_date", start)
|
||||||
|
object.__setattr__(self, "end_date", end)
|
||||||
|
if not isinstance(is_present, bool):
|
||||||
|
raise ValueError(
|
||||||
|
f"is_present must be a real bool, got {is_present!r}"
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "is_present", is_present)
|
||||||
|
if note is not None and not isinstance(note, str):
|
||||||
|
raise ValueError(f"note must be a string or None, got {note!r}")
|
||||||
|
object.__setattr__(self, "note", note)
|
||||||
|
|
||||||
|
def covers(self, date: datetime.date) -> bool:
|
||||||
|
"""Return True when the override applies to ``date``."""
|
||||||
|
return self.start_date <= date <= self.end_date
|
||||||
|
|
||||||
|
|
||||||
|
def cycle_week_index(
|
||||||
|
schedule: PresenceSchedule, target_date: object
|
||||||
|
) -> int:
|
||||||
|
"""Return the cycle week index ``target_date`` falls in.
|
||||||
|
|
||||||
|
Anchor-date arithmetic ONLY (D-004): ``((target_date -
|
||||||
|
anchor_date).days // 7) % cycle_length_weeks``. Python's floor
|
||||||
|
division makes dates BEFORE the anchor behave correctly — they
|
||||||
|
walk the cycle backwards (day -1 is the last week of the cycle,
|
||||||
|
day -8 wraps one further) instead of inverting, which is exactly
|
||||||
|
what ISO week parity does wrong in 53-week years. The modulo of
|
||||||
|
a negative in Python is non-negative, so the index is always a
|
||||||
|
valid week index.
|
||||||
|
"""
|
||||||
|
target = _parse_date(target_date, "target_date")
|
||||||
|
days = (target - schedule.anchor_date).days
|
||||||
|
return (days // 7) % schedule.cycle_length_weeks
|
||||||
|
|
||||||
|
|
||||||
|
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
|
||||||
|
#: years of daily checks. A child absent every day (an all-absent
|
||||||
|
#: pattern with a blocking override) would otherwise loop forever; the
|
||||||
|
#: cap bounds the scan and surfaces the situation as an error instead.
|
||||||
|
MAX_PREVIEW_SCAN_DAYS = 366 * 2
|
||||||
|
|
||||||
|
|
||||||
|
class PresenceEngine:
|
||||||
|
"""Answers "is this child at this house on this date?".
|
||||||
|
|
||||||
|
Pure and side-effect-free: built from an immutable snapshot of the
|
||||||
|
children's schedules (``child_id -> PresenceSchedule``), it never
|
||||||
|
touches the database — callers build a snapshot from the DAO layer
|
||||||
|
and pass it in. A child with NO schedule entry is present every
|
||||||
|
day (the third household child's case); a child WITH a schedule is
|
||||||
|
present exactly when the date's weekday is in that schedule's
|
||||||
|
pattern for the date's cycle week index (anchor-date arithmetic —
|
||||||
|
:func:`cycle_week_index`, never ISO week parity).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedules: dict[int, PresenceSchedule],
|
||||||
|
overrides: dict[int, list[PresenceOverride]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(schedules, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"schedules must map child_id to PresenceSchedule, got "
|
||||||
|
f"{schedules!r}"
|
||||||
|
)
|
||||||
|
for child_id, schedule in schedules.items():
|
||||||
|
# Validate the KEY as a model child id too: True and 1.0
|
||||||
|
# compare equal to 1, so {True: schedule(1)} would be
|
||||||
|
# accepted and then behave differently on lookup.
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
if not isinstance(schedule, PresenceSchedule):
|
||||||
|
raise ValueError(
|
||||||
|
f"schedules[{child_id!r}] must be a PresenceSchedule, "
|
||||||
|
f"got {schedule!r}"
|
||||||
|
)
|
||||||
|
if schedule.child_id != child_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"schedules is keyed {child_id!r} but holds the "
|
||||||
|
f"schedule of child {schedule.child_id}"
|
||||||
|
)
|
||||||
|
if overrides is None:
|
||||||
|
overrides = {}
|
||||||
|
if not isinstance(overrides, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"overrides must map child_id to a list of "
|
||||||
|
f"PresenceOverride, got {overrides!r}"
|
||||||
|
)
|
||||||
|
for child_id, entries in overrides.items():
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise ValueError(
|
||||||
|
f"overrides[{child_id!r}] must be a list of "
|
||||||
|
f"PresenceOverride, got {entries!r}"
|
||||||
|
)
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, PresenceOverride):
|
||||||
|
raise ValueError(
|
||||||
|
f"overrides[{child_id!r}] entries must be "
|
||||||
|
f"PresenceOverride, got {entry!r}"
|
||||||
|
)
|
||||||
|
if entry.child_id != child_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"overrides is keyed {child_id!r} but holds an "
|
||||||
|
f"override of child {entry.child_id}"
|
||||||
|
)
|
||||||
|
# Read-only snapshot: reassignment is refused and no mutator
|
||||||
|
# exists, so a built engine answers consistently for its
|
||||||
|
# lifetime. The overrides lists are copied to immutable
|
||||||
|
# tuples — a shallow mapping copy alone would leave caller-
|
||||||
|
# owned lists reachable through _overrides, and mutating one
|
||||||
|
# later would change is_present results.
|
||||||
|
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"_overrides",
|
||||||
|
MappingProxyType(
|
||||||
|
{child_id: tuple(entries) for child_id, entries in overrides.items()}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_present(self, child_id: object, date: object) -> bool:
|
||||||
|
"""Return whether ``child_id`` is present on ``date``.
|
||||||
|
|
||||||
|
Overrides take precedence over the repeating pattern: when an
|
||||||
|
override covers the date, its flag IS the answer — including
|
||||||
|
for a child with no schedule at all. The DAO layer guarantees
|
||||||
|
a child's overrides never overlap; if a snapshot still carries
|
||||||
|
two covering the same date, the one with the LATEST start date
|
||||||
|
wins (deterministic, and matches "the most specific swap wins").
|
||||||
|
"""
|
||||||
|
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||||
|
target = _parse_date(date, "date")
|
||||||
|
entries = self._overrides.get(child_id) or []
|
||||||
|
covering = [
|
||||||
|
entry for entry in entries if entry.covers(target)
|
||||||
|
]
|
||||||
|
if covering:
|
||||||
|
winner = max(covering, key=lambda entry: entry.start_date)
|
||||||
|
return winner.is_present
|
||||||
|
schedule = self._schedules.get(child_id)
|
||||||
|
if schedule is None:
|
||||||
|
# No schedule at all: present every day (household default).
|
||||||
|
return True
|
||||||
|
week = cycle_week_index(schedule, target)
|
||||||
|
return target.weekday() in schedule.pattern[week]
|
||||||
|
|
||||||
|
def next_present_dates(
|
||||||
|
self,
|
||||||
|
child_id: object,
|
||||||
|
start_date: object,
|
||||||
|
count: int,
|
||||||
|
) -> list[datetime.date]:
|
||||||
|
"""Return the next ``count`` dates the child is present.
|
||||||
|
|
||||||
|
Walks forward from ``start_date`` (INCLUSIVE — a parent
|
||||||
|
previewing "starting Monday" expects Monday itself when the
|
||||||
|
child is present) checking :meth:`is_present` for every day,
|
||||||
|
so overrides and the pattern are honoured identically. The
|
||||||
|
admin Schedule tab renders this preview so a parent can confirm
|
||||||
|
a custody pattern is right before saving it.
|
||||||
|
|
||||||
|
A child absent every scanned day raises ValueError after the
|
||||||
|
two-year search cap — an always-absent child would otherwise
|
||||||
|
loop forever. ``count`` must be a positive integer.
|
||||||
|
"""
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
start = _parse_date(start_date, "start_date")
|
||||||
|
if isinstance(count, bool) or not isinstance(count, int):
|
||||||
|
raise ValueError(f"count must be an integer, got {count!r}")
|
||||||
|
if count < 1:
|
||||||
|
raise ValueError(f"count must be at least 1, got {count}")
|
||||||
|
found: list[datetime.date] = []
|
||||||
|
day = start
|
||||||
|
for _ in range(MAX_PREVIEW_SCAN_DAYS):
|
||||||
|
if self.is_present(child_id, day):
|
||||||
|
found.append(day)
|
||||||
|
if len(found) == count:
|
||||||
|
return found
|
||||||
|
day += datetime.timedelta(days=1)
|
||||||
|
raise ValueError(
|
||||||
|
f"child {child_id} is not present on any of the "
|
||||||
|
f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; "
|
||||||
|
f"{count} present day(s) cannot be previewed"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __setattr__(self, name: object, value: object) -> None:
|
||||||
|
raise AttributeError(
|
||||||
|
"PresenceEngine is an immutable snapshot; build a new engine "
|
||||||
|
"instead of mutating this one"
|
||||||
|
)
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
"""Business layer for creating and editing quest definitions (Feature 06).
|
||||||
|
|
||||||
|
Sits between the Feature 09 service gate and the typed
|
||||||
|
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
|
||||||
|
all-or-nothing create/edit here, and never touch the DAO SQL directly.
|
||||||
|
:func:`create_quest_definition` is the create path,
|
||||||
|
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
|
||||||
|
:func:`unassign_child` the assignment paths, and
|
||||||
|
:func:`set_quest_definition_active` the deactivate/reactivate path.
|
||||||
|
There is no permission check here (that is Feature 09).
|
||||||
|
|
||||||
|
Validation policy mirrors :mod:`.children`:
|
||||||
|
|
||||||
|
- ``title`` is required and trimmed; empty or whitespace-only raises
|
||||||
|
ValueError.
|
||||||
|
- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
|
||||||
|
(its constructor is total — rule validation is NOT re-implemented
|
||||||
|
here).
|
||||||
|
- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``);
|
||||||
|
every id is a plain int (bools and floats rejected, since SQLite would
|
||||||
|
bind them onto a real child) and must reference an existing ACTIVE
|
||||||
|
child (the DAO enforces the active/exists check inside the same
|
||||||
|
transaction). Duplicate ids are rejected here, before the composite
|
||||||
|
primary key could surface them as an :class:`sqlite3.IntegrityError`.
|
||||||
|
- ``windows`` must be a non-empty list (strictly a ``list``); each entry
|
||||||
|
is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare
|
||||||
|
name (no due time) or a ``(name, due_time)`` pair whose due time is a
|
||||||
|
strict 24-hour HH:MM string. Duplicate window names are rejected
|
||||||
|
here, before the composite primary key could surface them as an
|
||||||
|
:class:`sqlite3.IntegrityError`.
|
||||||
|
|
||||||
|
The rule, definition, assignees and windows are persisted in ONE
|
||||||
|
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
|
||||||
|
so a rejected or failed create persists nothing. The definition-level
|
||||||
|
``due_time`` column is superseded by per-window due times (D-008) and is
|
||||||
|
never set here. ``created_at`` is stamped UTC ISO-8601 at second
|
||||||
|
precision, matching :mod:`.children`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .dao_children import ChildRecord, _connection_lock
|
||||||
|
from .dao_rules import (
|
||||||
|
QuestDefinitionRecord,
|
||||||
|
QuestDefinitionSnapshot,
|
||||||
|
QuestDefinitionWindowRecord,
|
||||||
|
QuestDefinitionsDao,
|
||||||
|
ScheduleRuleStorage,
|
||||||
|
ScheduleRulesDao,
|
||||||
|
_UNSET,
|
||||||
|
_validate_date,
|
||||||
|
_validate_time,
|
||||||
|
_validate_window,
|
||||||
|
schedule_rule_from_storage,
|
||||||
|
schedule_rule_storage_from_record,
|
||||||
|
schedule_rule_to_storage,
|
||||||
|
)
|
||||||
|
from .db import NestQuestDatabase
|
||||||
|
from .materialize import regenerate_for_definition
|
||||||
|
from .recurrence import ScheduleRule, occurs_on
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CreatedQuestDefinition:
|
||||||
|
"""The persisted result of a create, edit or activation change.
|
||||||
|
|
||||||
|
``definition`` is the stored row; ``rule`` is the schedule rule
|
||||||
|
decoded back through the storage mapping; ``assignees`` and
|
||||||
|
``windows`` are the definition's linked child records and window
|
||||||
|
declarations, read through the DAO.
|
||||||
|
"""
|
||||||
|
|
||||||
|
definition: QuestDefinitionRecord
|
||||||
|
rule: ScheduleRule
|
||||||
|
assignees: list[ChildRecord]
|
||||||
|
windows: list[QuestDefinitionWindowRecord]
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp() -> str:
|
||||||
|
"""Return the module's strict UTC second-precision stamp."""
|
||||||
|
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||||
|
timespec="seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_text(
|
||||||
|
value: object, field: str, *, required: bool = False
|
||||||
|
) -> str | None:
|
||||||
|
"""Validate a free-text field and return it trimmed.
|
||||||
|
|
||||||
|
``None`` raises when ``required`` and otherwise returns None. A
|
||||||
|
non-string value is rejected; an empty result is rejected for
|
||||||
|
required fields.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
if required:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||||
|
trimmed = value.strip()
|
||||||
|
if required and not trimmed:
|
||||||
|
raise ValueError(f"{field} is required")
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_assignee_ids(value: object) -> list[int]:
|
||||||
|
"""Return the assignee ids as plain ints, or raise naming the field.
|
||||||
|
|
||||||
|
The argument must be a non-empty list — strictly a ``list``: tuples
|
||||||
|
and other sequences are rejected. Each entry must be a real int
|
||||||
|
(bools and floats rejected) so SQLite cannot bind ``True`` onto
|
||||||
|
child 1 or a float onto a neighbouring profile.
|
||||||
|
"""
|
||||||
|
if type(value) is not list:
|
||||||
|
raise ValueError(
|
||||||
|
f"assignee_child_ids must be a list, got {type(value).__name__}"
|
||||||
|
)
|
||||||
|
if not value:
|
||||||
|
raise ValueError("assignee_child_ids must not be empty")
|
||||||
|
ids: list[int] = []
|
||||||
|
for entry in value:
|
||||||
|
if isinstance(entry, bool) or not isinstance(entry, int):
|
||||||
|
raise ValueError(
|
||||||
|
"assignee_child_ids must contain integer child ids, "
|
||||||
|
f"got {entry!r}"
|
||||||
|
)
|
||||||
|
ids.append(entry)
|
||||||
|
if len(set(ids)) != len(ids):
|
||||||
|
raise ValueError(
|
||||||
|
"assignee_child_ids must not contain duplicate child ids"
|
||||||
|
)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_windows(
|
||||||
|
value: object,
|
||||||
|
) -> list[tuple[str, str | None]]:
|
||||||
|
"""Normalize the windows argument to ``(name, due_time)`` pairs.
|
||||||
|
|
||||||
|
The outer argument must be a non-empty list — strictly a ``list``:
|
||||||
|
tuples and other sequences are rejected, while each individual entry
|
||||||
|
may still be a ``(window, due_time)`` tuple. A bare window name
|
||||||
|
(due time None) is also accepted. The name must be a
|
||||||
|
:data:`~.const.QUEST_WINDOWS` spelling and a present due time must be
|
||||||
|
a strict 24-hour HH:MM, both validated up front so a malformed
|
||||||
|
window is rejected before any write.
|
||||||
|
"""
|
||||||
|
if type(value) is not list:
|
||||||
|
raise ValueError(
|
||||||
|
f"windows must be a list, got {type(value).__name__}"
|
||||||
|
)
|
||||||
|
if not value:
|
||||||
|
raise ValueError("windows must not be empty")
|
||||||
|
normalized: list[tuple[str, str | None]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for index, entry in enumerate(value):
|
||||||
|
if isinstance(entry, str):
|
||||||
|
name: object = entry
|
||||||
|
due_time: object = None
|
||||||
|
elif isinstance(entry, (tuple, list)) and len(entry) == 2:
|
||||||
|
name, due_time = entry[0], entry[1]
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"windows[{index}] must be a window name or a "
|
||||||
|
f"(name, due_time) pair, got {entry!r}"
|
||||||
|
)
|
||||||
|
_validate_window(name)
|
||||||
|
if due_time is not None:
|
||||||
|
_validate_time(due_time, "due_time")
|
||||||
|
if name in seen:
|
||||||
|
raise ValueError(
|
||||||
|
f"windows must not contain duplicate window {name!r}"
|
||||||
|
)
|
||||||
|
seen.add(name)
|
||||||
|
normalized.append((name, due_time))
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_definition_id(value: object) -> int:
|
||||||
|
"""Reject non-int definition ids (bools included) before any lookup.
|
||||||
|
|
||||||
|
SQLite binds Python bools as integers, so ``True`` would silently
|
||||||
|
address definition 1 and a float would round — malformed service
|
||||||
|
input must never mutate the wrong definition.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"definition_id must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_child_id(value: object) -> int:
|
||||||
|
"""Reject non-int child ids (bools included) before any lookup.
|
||||||
|
|
||||||
|
SQLite binds Python bools as integers, so ``True`` would silently
|
||||||
|
address child 1 and a float would round — malformed service input
|
||||||
|
must never mutate the wrong profile.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def create_quest_definition(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
title: str,
|
||||||
|
rule: ScheduleRule,
|
||||||
|
assignee_child_ids: list[int],
|
||||||
|
windows: list[str | tuple[str, str | None]],
|
||||||
|
*,
|
||||||
|
description: str | None = None,
|
||||||
|
icon: str | None = None,
|
||||||
|
) -> CreatedQuestDefinition:
|
||||||
|
"""Create a quest definition (rule + assignees + windows) atomically.
|
||||||
|
|
||||||
|
Validates the arguments up front, then persists the schedule rule,
|
||||||
|
the definition, its assignees and its windows in one transaction via
|
||||||
|
the DAO. Returns the stored definition together with its decoded
|
||||||
|
rule, assignees and windows — all read back inside that same
|
||||||
|
transaction, so the returned bundle is a consistent creation
|
||||||
|
snapshot. Raises ValueError on any rejected argument; a rejected or
|
||||||
|
failed create persists nothing.
|
||||||
|
"""
|
||||||
|
name = _validate_text(title, "title", required=True)
|
||||||
|
assert name is not None
|
||||||
|
if not isinstance(rule, ScheduleRule):
|
||||||
|
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
|
||||||
|
assignee_ids = _validate_assignee_ids(assignee_child_ids)
|
||||||
|
window_specs = _normalize_windows(windows)
|
||||||
|
description_value = _validate_text(description, "description")
|
||||||
|
icon_value = _validate_text(icon, "icon")
|
||||||
|
|
||||||
|
storage = schedule_rule_to_storage(rule)
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
try:
|
||||||
|
snapshot = await dao.create_with_rule_and_windows(
|
||||||
|
name,
|
||||||
|
storage,
|
||||||
|
_now_stamp(),
|
||||||
|
assignee_ids,
|
||||||
|
window_specs,
|
||||||
|
description=description_value,
|
||||||
|
icon=icon_value,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
# The DAO's assignee check names only the child; re-raise so the
|
||||||
|
# public contract names the field the caller actually passed.
|
||||||
|
message = str(error)
|
||||||
|
if message.startswith("child "):
|
||||||
|
raise ValueError(
|
||||||
|
f"assignee_child_ids: {message}"
|
||||||
|
) from error
|
||||||
|
raise
|
||||||
|
decoded_rule = schedule_rule_from_storage(storage)
|
||||||
|
return CreatedQuestDefinition(
|
||||||
|
definition=snapshot.definition,
|
||||||
|
rule=decoded_rule,
|
||||||
|
assignees=snapshot.assignees,
|
||||||
|
windows=snapshot.windows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def edit_quest_definition(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
definition_id: int,
|
||||||
|
*,
|
||||||
|
title: str | object = _UNSET,
|
||||||
|
description: str | None | object = _UNSET,
|
||||||
|
icon: str | None | object = _UNSET,
|
||||||
|
rule: ScheduleRule | object = _UNSET,
|
||||||
|
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
||||||
|
) -> CreatedQuestDefinition:
|
||||||
|
"""Edit a quest definition's metadata, rule and windows atomically.
|
||||||
|
|
||||||
|
Arguments default to the sentinel ``_UNSET`` meaning "leave this
|
||||||
|
field alone". A provided ``title`` is validated like create's
|
||||||
|
(required and trimmed); ``description`` and ``icon`` are optional
|
||||||
|
and may be cleared by passing ``None`` explicitly. A provided
|
||||||
|
``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
|
||||||
|
and REPLACES the definition's schedule rule IN PLACE — the existing
|
||||||
|
``schedule_rules`` row is updated, never orphaned or duplicated. A
|
||||||
|
provided ``windows`` list REPLACES the whole window set (each entry
|
||||||
|
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
|
||||||
|
time), matching create's non-empty contract. Assignment is NOT
|
||||||
|
settable here. Raises ValueError when the definition does not
|
||||||
|
exist or any argument is rejected.
|
||||||
|
|
||||||
|
Returns the updated definition together with its decoded rule,
|
||||||
|
assignees and windows — all read back inside the DAO's transaction,
|
||||||
|
so the returned bundle is a consistent edit snapshot. Edits change
|
||||||
|
future instances only; no ``quest_instances`` or
|
||||||
|
``completion_events`` row is touched.
|
||||||
|
"""
|
||||||
|
_validate_definition_id(definition_id)
|
||||||
|
|
||||||
|
title_value: str | None | object = _UNSET
|
||||||
|
if title is not _UNSET:
|
||||||
|
name = _validate_text(title, "title", required=True)
|
||||||
|
assert name is not None
|
||||||
|
title_value = name
|
||||||
|
|
||||||
|
description_value: str | None | object = _UNSET
|
||||||
|
if description is not _UNSET:
|
||||||
|
description_value = _validate_text(description, "description")
|
||||||
|
|
||||||
|
icon_value: str | None | object = _UNSET
|
||||||
|
if icon is not _UNSET:
|
||||||
|
icon_value = _validate_text(icon, "icon")
|
||||||
|
|
||||||
|
rule_storage: ScheduleRuleStorage | object = _UNSET
|
||||||
|
if rule is not _UNSET:
|
||||||
|
if not isinstance(rule, ScheduleRule):
|
||||||
|
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
|
||||||
|
rule_storage = schedule_rule_to_storage(rule)
|
||||||
|
|
||||||
|
window_specs: list[tuple[str, str | None]] | object = _UNSET
|
||||||
|
if windows is not _UNSET:
|
||||||
|
window_specs = _normalize_windows(windows)
|
||||||
|
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
try:
|
||||||
|
snapshot = await dao.edit_definition(
|
||||||
|
definition_id,
|
||||||
|
title=title_value,
|
||||||
|
description=description_value,
|
||||||
|
icon=icon_value,
|
||||||
|
rule=rule_storage,
|
||||||
|
windows=window_specs,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
# The DAO's missing-definition error names only the id; re-raise
|
||||||
|
# so the public contract names the field the caller passed (matching
|
||||||
|
# assign/unassign/deactivate).
|
||||||
|
message = str(error)
|
||||||
|
if message.startswith("quest definition "):
|
||||||
|
raise ValueError(f"definition_id: {message}") from error
|
||||||
|
raise
|
||||||
|
|
||||||
|
decoded_rule = schedule_rule_from_storage(
|
||||||
|
schedule_rule_storage_from_record(snapshot.rule)
|
||||||
|
)
|
||||||
|
|
||||||
|
await regenerate_for_definition(database, definition_id)
|
||||||
|
|
||||||
|
return CreatedQuestDefinition(
|
||||||
|
definition=snapshot.definition,
|
||||||
|
rule=decoded_rule,
|
||||||
|
assignees=snapshot.assignees,
|
||||||
|
windows=snapshot.windows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def assign_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
definition_id: int,
|
||||||
|
child_id: int,
|
||||||
|
) -> list[ChildRecord]:
|
||||||
|
"""Assign ``child_id`` to the definition (idempotent).
|
||||||
|
|
||||||
|
Validates that both ids are plain ints (bools and floats rejected)
|
||||||
|
and that the child exists and is active — the DAO enforces the
|
||||||
|
active/exists check inside its transaction, and also confirms the
|
||||||
|
definition exists. Assigning an already-assigned child is a no-op
|
||||||
|
(no duplicate row). Returns the definition's assignees read back
|
||||||
|
inside the DAO's same transaction, a consistent post-assignment
|
||||||
|
roster (a concurrent assign/unassign cannot race it after the
|
||||||
|
write). Raises ValueError on any rejected argument. Only the
|
||||||
|
``quest_definition_assignees`` link is written; existing instances
|
||||||
|
and completion history are never touched.
|
||||||
|
"""
|
||||||
|
_validate_definition_id(definition_id)
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
try:
|
||||||
|
assignees = await dao.add_assignee(definition_id, child_id)
|
||||||
|
except ValueError as error:
|
||||||
|
# The DAO names only the child/definition; re-raise so the
|
||||||
|
# public contract names the field the caller actually passed.
|
||||||
|
message = str(error)
|
||||||
|
if message.startswith("child "):
|
||||||
|
raise ValueError(f"child_id: {message}") from error
|
||||||
|
if message.startswith("quest definition "):
|
||||||
|
raise ValueError(f"definition_id: {message}") from error
|
||||||
|
raise
|
||||||
|
await regenerate_for_definition(database, definition_id)
|
||||||
|
return assignees
|
||||||
|
|
||||||
|
|
||||||
|
async def unassign_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
definition_id: int,
|
||||||
|
child_id: int,
|
||||||
|
) -> bool:
|
||||||
|
"""Unassign ``child_id`` from the definition; True when removed.
|
||||||
|
|
||||||
|
Validates both ids are plain ints (bools and floats rejected) and
|
||||||
|
that the definition exists, then removes the (definition, child)
|
||||||
|
link. Returns True when a row was removed and False when the child
|
||||||
|
was not currently assigned. Raises ValueError on a rejected
|
||||||
|
argument. Only the ``quest_definition_assignees`` link is written;
|
||||||
|
existing instances and completion history are never touched.
|
||||||
|
"""
|
||||||
|
_validate_definition_id(definition_id)
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
definition = await dao.get(definition_id)
|
||||||
|
if definition is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"definition_id: quest definition {definition_id} does not exist"
|
||||||
|
)
|
||||||
|
removed = await dao.remove_assignee(definition_id, child_id)
|
||||||
|
await regenerate_for_definition(database, definition_id)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
async def set_quest_definition_active(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
definition_id: int,
|
||||||
|
is_active: bool,
|
||||||
|
) -> CreatedQuestDefinition:
|
||||||
|
"""Deactivate or reactivate a definition; returns the updated view.
|
||||||
|
|
||||||
|
Flipping the flag is the only removal path (Feature 06 guardrail):
|
||||||
|
a deactivated definition stops future instance generation but its
|
||||||
|
row, schedule rule, assignees and windows all survive, and existing
|
||||||
|
instances plus completion history are never touched. Reactivation
|
||||||
|
re-enables future generation; generating or removing instances is
|
||||||
|
Feature 07 and does not happen here.
|
||||||
|
|
||||||
|
``definition_id`` must be a plain int (bools and floats rejected,
|
||||||
|
since SQLite would bind ``True`` onto definition 1) and
|
||||||
|
``is_active`` must be a real bool — the DAO's ``int()`` would
|
||||||
|
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||||
|
state the caller never asked for. Raises ValueError naming the
|
||||||
|
offending field when the id is malformed, ``is_active`` is not a
|
||||||
|
bool, or the definition does not exist.
|
||||||
|
|
||||||
|
Atomicity: the existence check, the UPDATE and the readback of the
|
||||||
|
definition, its rule, assignees and windows all run inside the
|
||||||
|
connection-scoped lock, so the returned bundle is a consistent
|
||||||
|
snapshot that a concurrent mutation cannot race.
|
||||||
|
"""
|
||||||
|
_validate_definition_id(definition_id)
|
||||||
|
if not isinstance(is_active, bool):
|
||||||
|
raise ValueError(f"is_active must be a real bool, got {is_active!r}")
|
||||||
|
async with _connection_lock(database):
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
if await dao.get(definition_id) is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"definition_id: quest definition {definition_id} "
|
||||||
|
"does not exist"
|
||||||
|
)
|
||||||
|
await dao.set_active(definition_id, is_active)
|
||||||
|
updated = await dao.get(definition_id)
|
||||||
|
assert updated is not None
|
||||||
|
rule_record = await ScheduleRulesDao(database).get(
|
||||||
|
updated.schedule_rule_id
|
||||||
|
)
|
||||||
|
assert rule_record is not None
|
||||||
|
assignees = await dao.list_assignees(definition_id)
|
||||||
|
windows = await dao.list_windows(definition_id)
|
||||||
|
rule = schedule_rule_from_storage(
|
||||||
|
schedule_rule_storage_from_record(rule_record)
|
||||||
|
)
|
||||||
|
await regenerate_for_definition(database, definition_id)
|
||||||
|
return CreatedQuestDefinition(
|
||||||
|
definition=updated,
|
||||||
|
rule=rule,
|
||||||
|
assignees=assignees,
|
||||||
|
windows=windows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_snapshot(
|
||||||
|
snapshot: QuestDefinitionSnapshot,
|
||||||
|
) -> CreatedQuestDefinition:
|
||||||
|
"""Decode a DAO snapshot into the layer's rich view.
|
||||||
|
|
||||||
|
The rule record carried by the snapshot is the one the definition
|
||||||
|
referenced at read time, so decoding it here (rather than re-reading
|
||||||
|
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
|
||||||
|
coherence the DAO's locked snapshot guarantees: the rule, assignees
|
||||||
|
and windows all come from one atomic read, never three separate ones.
|
||||||
|
"""
|
||||||
|
rule = schedule_rule_from_storage(
|
||||||
|
schedule_rule_storage_from_record(snapshot.rule)
|
||||||
|
)
|
||||||
|
return CreatedQuestDefinition(
|
||||||
|
definition=snapshot.definition,
|
||||||
|
rule=rule,
|
||||||
|
assignees=snapshot.assignees,
|
||||||
|
windows=snapshot.windows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_active_definitions(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
) -> list[CreatedQuestDefinition]:
|
||||||
|
"""Return every ACTIVE definition with its decoded rule and links.
|
||||||
|
|
||||||
|
Ordered by rising definition id, matching the DAO's ``list_active``
|
||||||
|
order, so callers get a stable, repeatable sequence. Each entry is
|
||||||
|
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
|
||||||
|
the layer: the stored row plus its decoded
|
||||||
|
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
|
||||||
|
definitions and their links are read by the DAO inside one locked
|
||||||
|
transaction, so each bundle is a coherent snapshot that a concurrent
|
||||||
|
edit cannot interleave.
|
||||||
|
"""
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
snapshots = await dao.list_snapshots_active()
|
||||||
|
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_definitions_for_child(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
child_id: int,
|
||||||
|
) -> list[CreatedQuestDefinition]:
|
||||||
|
"""Return the definitions assigned to ``child_id`` (rich view).
|
||||||
|
|
||||||
|
``child_id`` must be a plain int (bools and floats rejected, since
|
||||||
|
SQLite would bind ``True`` onto child 1). Results are ordered by
|
||||||
|
rising definition id, matching ``list_active_definitions`` and
|
||||||
|
``list_definitions_firing_on``. Each entry is a
|
||||||
|
:class:`CreatedQuestDefinition` snapshot bundling the definition with
|
||||||
|
its decoded rule, assignees and windows, read atomically by the DAO.
|
||||||
|
"""
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
snapshots = await dao.list_snapshots_by_child(child_id)
|
||||||
|
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||||
|
return sorted(bundles, key=lambda bundle: bundle.definition.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_definitions_firing_on(
|
||||||
|
database: NestQuestDatabase,
|
||||||
|
date: str,
|
||||||
|
) -> list[CreatedQuestDefinition]:
|
||||||
|
"""Return the ACTIVE definitions whose rule fires on ``date``.
|
||||||
|
|
||||||
|
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
|
||||||
|
value raises ValueError naming the field. Each active definition's
|
||||||
|
stored rule is decoded through the task-1 storage mapping and handed
|
||||||
|
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
|
||||||
|
definitions whose decoded rule fires on the date are returned, in
|
||||||
|
rising definition-id order, each as the same rich
|
||||||
|
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
|
||||||
|
The definitions and their rules are read inside one locked
|
||||||
|
transaction, so a bundle can never filter on one rule while
|
||||||
|
presenting another state's windows.
|
||||||
|
"""
|
||||||
|
_validate_date(date, "date")
|
||||||
|
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
snapshots = await dao.list_snapshots_active()
|
||||||
|
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||||
|
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
|
||||||
@@ -159,11 +159,12 @@ self-describing without joins.
|
|||||||
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
||||||
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
||||||
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
||||||
NULL — the tapped child profile is already ``child_id``). A coherence
|
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
|
||||||
CHECK makes the pair shape non-negotiable: ``'user'`` requires
|
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
|
||||||
``actor_user_id``, ``'panel'`` forbids it. Other actor sources are
|
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
|
||||||
rejected; if an unattended system actor is ever needed, that is a
|
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
|
||||||
schema migration, not a silent widening.
|
Other actor sources are rejected; if an unattended system actor is
|
||||||
|
ever needed, that is a schema migration, not a silent widening.
|
||||||
|
|
||||||
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
||||||
``completed`` events — at completion time the due date is known and the
|
``completed`` events — at completion time the due date is known and the
|
||||||
@@ -344,29 +345,42 @@ SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
|
|||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
#: Column body of ``completion_events``, shared between the v1 DDL
|
||||||
"""
|
#: below and the actor-child rebuild migration, which must recreate the
|
||||||
CREATE TABLE IF NOT EXISTS completion_events (
|
#: table in exactly this shape (D-008: ``actor_child_id`` records which
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
#: panel profile was tapped — set for panel events, NULL for admin
|
||||||
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
#: ones; legacy panel rows backfill to ``child_id``, the child they
|
||||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
#: always were).
|
||||||
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
COMPLETION_EVENTS_TABLE_SQL = """(
|
||||||
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
actor_user_id TEXT,
|
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
||||||
occurred_at TEXT NOT NULL,
|
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||||
was_on_time INTEGER CHECK (
|
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
||||||
was_on_time IS NULL
|
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
||||||
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
actor_user_id TEXT,
|
||||||
),
|
actor_child_id INTEGER REFERENCES children(id),
|
||||||
CHECK (
|
occurred_at TEXT NOT NULL,
|
||||||
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
was_on_time INTEGER CHECK (
|
||||||
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
was_on_time IS NULL
|
||||||
),
|
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
||||||
CHECK (
|
),
|
||||||
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
CHECK (
|
||||||
OR (event_type = 'uncompleted')
|
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
||||||
)
|
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
||||||
|
),
|
||||||
|
CHECK (
|
||||||
|
(actor_source = 'user' AND actor_child_id IS NULL)
|
||||||
|
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
|
||||||
|
),
|
||||||
|
CHECK (
|
||||||
|
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
||||||
|
OR (event_type = 'uncompleted')
|
||||||
)
|
)
|
||||||
|
)"""
|
||||||
|
|
||||||
|
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
|
||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,16 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"horizon_days": "Generation horizon (days)",
|
"horizon_days": "Generation horizon (days)",
|
||||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||||
"panel_idle_timeout": "Panel idle timeout (seconds)"
|
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||||
|
"admin_user_ids": "Admin users (parents)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalid": "Invalid value.",
|
"invalid": "Invalid value.",
|
||||||
"invalid_time": "Time must use 24-hour HH:MM format."
|
"invalid_time": "Time must use 24-hour HH:MM format.",
|
||||||
|
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
|
||||||
|
"invalid_admin": "Invalid admin selection."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,13 +22,16 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"horizon_days": "Generation horizon (days)",
|
"horizon_days": "Generation horizon (days)",
|
||||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||||
"panel_idle_timeout": "Panel idle timeout (seconds)"
|
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||||
|
"admin_user_ids": "Admin users (parents)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalid": "Invalid value.",
|
"invalid": "Invalid value.",
|
||||||
"invalid_time": "Time must use 24-hour HH:MM format."
|
"invalid_time": "Time must use 24-hour HH:MM format.",
|
||||||
|
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
|
||||||
|
"invalid_admin": "Invalid admin selection."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+118
-4
@@ -29,7 +29,7 @@ import tempfile
|
|||||||
import weakref
|
import weakref
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -42,6 +42,9 @@ _HA_MODULES = (
|
|||||||
"homeassistant.config_entries",
|
"homeassistant.config_entries",
|
||||||
"homeassistant.data_entry_flow",
|
"homeassistant.data_entry_flow",
|
||||||
"homeassistant.exceptions",
|
"homeassistant.exceptions",
|
||||||
|
"homeassistant.helpers",
|
||||||
|
"homeassistant.helpers.config_validation",
|
||||||
|
"homeassistant.helpers.event",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _find_spec(name: str):
|
def _find_spec(name: str):
|
||||||
@@ -79,6 +82,9 @@ for _parent, _child in (
|
|||||||
("homeassistant", "data_entry_flow"),
|
("homeassistant", "data_entry_flow"),
|
||||||
("homeassistant", "core"),
|
("homeassistant", "core"),
|
||||||
("homeassistant", "exceptions"),
|
("homeassistant", "exceptions"),
|
||||||
|
("homeassistant", "helpers"),
|
||||||
|
("homeassistant.helpers", "config_validation"),
|
||||||
|
("homeassistant.helpers", "event"),
|
||||||
):
|
):
|
||||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||||
|
|
||||||
@@ -181,6 +187,30 @@ class ConfigEntryNotReady(Exception):
|
|||||||
|
|
||||||
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
|
||||||
|
def _multi_select(choices: dict):
|
||||||
|
"""Mirror homeassistant.helpers.config_validation.multi_select.
|
||||||
|
|
||||||
|
The options flow's admin picker uses HA's supported multi-select
|
||||||
|
validator (schema-serializable by HA's frontend); the mock provides
|
||||||
|
the same contract: a list whose members are keys of ``choices``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _validate(selected):
|
||||||
|
if not isinstance(selected, list):
|
||||||
|
raise vol.Invalid("Not a list")
|
||||||
|
for value in selected:
|
||||||
|
if value not in choices:
|
||||||
|
raise vol.Invalid(f"{value} is not a valid option")
|
||||||
|
return selected
|
||||||
|
|
||||||
|
return _validate
|
||||||
|
|
||||||
|
|
||||||
|
_ha_mock("homeassistant.helpers.config_validation").multi_select = _multi_select
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared helpers for the behavior tests.
|
# Shared helpers for the behavior tests.
|
||||||
@@ -214,16 +244,85 @@ class ListenerRegistry:
|
|||||||
return sum(len(listeners) for listeners in self._listeners.values())
|
return sum(len(listeners) for listeners in self._listeners.values())
|
||||||
|
|
||||||
|
|
||||||
|
class TimeChangeRegistry:
|
||||||
|
"""Models HA's time-change listener bookkeeping.
|
||||||
|
|
||||||
|
``track`` mirrors ``homeassistant.helpers.event.async_track_time_change``:
|
||||||
|
each call appends a record (action + hour/minute/second) and returns
|
||||||
|
a remove callable that reverses the registration. Tests inspect
|
||||||
|
``registrations`` and exercise the newest action through :meth:`fire`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hass=None):
|
||||||
|
self._hass = hass
|
||||||
|
self._registrations: list[dict] = []
|
||||||
|
|
||||||
|
def track(self, action, hour=None, minute=None, second=None):
|
||||||
|
record = {
|
||||||
|
"action": action,
|
||||||
|
"hour": hour,
|
||||||
|
"minute": minute,
|
||||||
|
"second": second,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _remove():
|
||||||
|
self._registrations.remove(record)
|
||||||
|
|
||||||
|
record["remove"] = _remove
|
||||||
|
self._registrations.append(record)
|
||||||
|
return _remove
|
||||||
|
|
||||||
|
@property
|
||||||
|
def registrations(self):
|
||||||
|
"""Return a copy of the live registrations."""
|
||||||
|
return list(self._registrations)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> int:
|
||||||
|
"""Number of live registrations."""
|
||||||
|
return len(self._registrations)
|
||||||
|
|
||||||
|
async def fire(self, now=None, index: int = -1):
|
||||||
|
"""Run a registered action as HA's scheduler would, awaiting coroutines."""
|
||||||
|
result = self._registrations[index]["action"](now)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
|
||||||
|
|
||||||
|
def _async_track_time_change(hass, action, hour=None, minute=None, second=None):
|
||||||
|
"""Mirror homeassistant.helpers.event.async_track_time_change.
|
||||||
|
|
||||||
|
Exact HA 2024.6 signature — no ``local`` keyword: the local-time
|
||||||
|
wrapper is already this function. Routes to the registry attached to
|
||||||
|
``hass`` (created on demand), so tests reach the registrations via
|
||||||
|
``hass.time_change``.
|
||||||
|
"""
|
||||||
|
registry = getattr(hass, "time_change", None)
|
||||||
|
if registry is None:
|
||||||
|
registry = TimeChangeRegistry(hass)
|
||||||
|
hass.time_change = registry
|
||||||
|
return registry.track(action, hour=hour, minute=minute, second=second)
|
||||||
|
|
||||||
|
|
||||||
|
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
|
||||||
|
|
||||||
|
|
||||||
def make_config_entry(
|
def make_config_entry(
|
||||||
entry_id: str = "test_entry",
|
entry_id: str = "test_entry",
|
||||||
options: dict | None = None,
|
options: dict | None = None,
|
||||||
data: dict | None = None,
|
data: dict | None = None,
|
||||||
|
context: dict | None = None,
|
||||||
):
|
):
|
||||||
"""Create a stub config entry shaped like HA's ConfigEntry for tests."""
|
"""Create a stub config entry shaped like HA's ConfigEntry for tests.
|
||||||
|
|
||||||
|
``context`` mirrors HA's entry context (the flow that created the
|
||||||
|
entry carries the acting HA user under ``context["user_id"]``).
|
||||||
|
"""
|
||||||
entry = SimpleNamespace(
|
entry = SimpleNamespace(
|
||||||
entry_id=entry_id,
|
entry_id=entry_id,
|
||||||
options=dict(options or {}),
|
options=dict(options or {}),
|
||||||
data=dict(data or {}),
|
data=dict(data or {}),
|
||||||
|
context=dict(context or {}),
|
||||||
)
|
)
|
||||||
entry.add_update_listener = None
|
entry.add_update_listener = None
|
||||||
return entry
|
return entry
|
||||||
@@ -247,6 +346,10 @@ def make_hass() -> tuple:
|
|||||||
config=MagicMock(),
|
config=MagicMock(),
|
||||||
config_entries=SimpleNamespace(async_reload=None),
|
config_entries=SimpleNamespace(async_reload=None),
|
||||||
)
|
)
|
||||||
|
# hass.auth mirrors the real surface the integration reads: an
|
||||||
|
# async_get_users list the tests can repoint at will (the setup
|
||||||
|
# path resolves HA owner accounts as the last-resort admin seed).
|
||||||
|
hass.auth = SimpleNamespace(async_get_users=AsyncMock(return_value=[]))
|
||||||
|
|
||||||
def _cleanup_config_dir():
|
def _cleanup_config_dir():
|
||||||
import shutil
|
import shutil
|
||||||
@@ -265,6 +368,10 @@ def make_hass() -> tuple:
|
|||||||
|
|
||||||
hass.async_add_executor_job = _run_executor_job
|
hass.async_add_executor_job = _run_executor_job
|
||||||
registry = ListenerRegistry(hass)
|
registry = ListenerRegistry(hass)
|
||||||
|
hass.time_change = TimeChangeRegistry(hass)
|
||||||
|
# A valid IANA time zone (the day-rollover listener reads it to
|
||||||
|
# compute "today" in HA local time).
|
||||||
|
hass.config.time_zone = "UTC"
|
||||||
|
|
||||||
async def _async_reload(entry_id: str) -> None:
|
async def _async_reload(entry_id: str) -> None:
|
||||||
registry.reloaded.append(entry_id)
|
registry.reloaded.append(entry_id)
|
||||||
@@ -302,8 +409,15 @@ async def hass():
|
|||||||
def make_entry():
|
def make_entry():
|
||||||
"""Provide the config-entry factory as a fixture."""
|
"""Provide the config-entry factory as a fixture."""
|
||||||
|
|
||||||
def _factory(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None):
|
def _factory(
|
||||||
return make_config_entry(entry_id=entry_id, options=options, data=data)
|
entry_id: str = "test_entry",
|
||||||
|
options: dict | None = None,
|
||||||
|
data: dict | None = None,
|
||||||
|
context: dict | None = None,
|
||||||
|
):
|
||||||
|
return make_config_entry(
|
||||||
|
entry_id=entry_id, options=options, data=data, context=context
|
||||||
|
)
|
||||||
|
|
||||||
return _factory
|
return _factory
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
"""Tests for admin_allowlist.py: the Feature 03 allowlist business layer."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
||||||
|
from custom_components.nestquest.admin_allowlist import (
|
||||||
|
list_admin_ids,
|
||||||
|
seed_setup_admin,
|
||||||
|
set_admin_ids,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.dao_children import AdminUsersDao
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
from custom_components.nestquest.migrations import apply_migrations
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
return asyncio.new_event_loop().run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_hass_mock():
|
||||||
|
hass = MagicMock()
|
||||||
|
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||||
|
return hass
|
||||||
|
|
||||||
|
|
||||||
|
def _with_db(tmp_path, name):
|
||||||
|
def _run_test(body):
|
||||||
|
async def _main():
|
||||||
|
database = NestQuestDatabase(_make_hass_mock())
|
||||||
|
await database.open(tmp_path / name)
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
return await body(database)
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
return _run(_main())
|
||||||
|
|
||||||
|
return _run_test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# set_admin_ids: read/write with the fail-closed guarantee
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_admin_ids_round_trip(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
await set_admin_ids(database, ["user-owner", "user-parent"])
|
||||||
|
# Membership is the contract; AdminUsersDao.list orders by id.
|
||||||
|
assert sorted(await list_admin_ids(database)) == [
|
||||||
|
"user-owner",
|
||||||
|
"user-parent",
|
||||||
|
]
|
||||||
|
# Replace: the removed id is gone, the new one present.
|
||||||
|
await set_admin_ids(database, ["user-parent", "user-grandma"])
|
||||||
|
assert sorted(await list_admin_ids(database)) == [
|
||||||
|
"user-grandma",
|
||||||
|
"user-parent",
|
||||||
|
]
|
||||||
|
return await list_admin_ids(database)
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-round-trip.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_admin_ids_rejects_empty_allowlist(tmp_path) -> None:
|
||||||
|
"""Removing the last admin (or saving an empty selection) is
|
||||||
|
refused with a clear error: an empty allowlist fails closed, so
|
||||||
|
the owner must never be able to reach one through this API."""
|
||||||
|
async def _body(database):
|
||||||
|
await set_admin_ids(database, ["user-owner"])
|
||||||
|
with pytest.raises(ValueError, match="at least one admin"):
|
||||||
|
await set_admin_ids(database, [])
|
||||||
|
# The refused write left the allowlist intact.
|
||||||
|
assert await list_admin_ids(database) == ["user-owner"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-empty-refused.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad", [None, "notalist", [42], [None], [""], [" "], 5]
|
||||||
|
)
|
||||||
|
def test_set_admin_ids_rejects_malformed_input(tmp_path, bad) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await set_admin_ids(database, bad)
|
||||||
|
assert await list_admin_ids(database) == []
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-malformed.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_admin_ids_rejects_duplicates(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="duplicates"):
|
||||||
|
await set_admin_ids(database, ["user-a", "user-a"])
|
||||||
|
assert await list_admin_ids(database) == []
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-duplicates.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_admin_ids_trims_and_replaces_atomically(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
await set_admin_ids(database, [" user-a "])
|
||||||
|
assert await list_admin_ids(database) == ["user-a"]
|
||||||
|
# Sync that both adds and removes in one write.
|
||||||
|
await set_admin_ids(database, ["user-b", "user-c"])
|
||||||
|
assert await list_admin_ids(database) == ["user-b", "user-c"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-trim.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# seed_setup_admin: the owner is never locked out on first setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_adds_context_user_on_first_setup(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
seeded = await seed_setup_admin(
|
||||||
|
database, stored_admin_ids=None, context_user_id="owner-1"
|
||||||
|
)
|
||||||
|
assert seeded == ["owner-1"]
|
||||||
|
assert await list_admin_ids(database) == ["owner-1"]
|
||||||
|
return seeded
|
||||||
|
|
||||||
|
_with_db(tmp_path, "seed-context.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_prefers_stored_entry_ids_over_context(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
seeded = await seed_setup_admin(
|
||||||
|
database,
|
||||||
|
stored_admin_ids=["user-a", "user-b"],
|
||||||
|
context_user_id="owner-1",
|
||||||
|
)
|
||||||
|
assert seeded == ["user-a", "user-b"]
|
||||||
|
return seeded
|
||||||
|
|
||||||
|
_with_db(tmp_path, "seed-stored.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_leaves_nonempty_allowlist_untouched(tmp_path) -> None:
|
||||||
|
"""Re-running setup must never resurrect a deliberately narrowed
|
||||||
|
list: only an EMPTY allowlist is seeded."""
|
||||||
|
async def _body(database):
|
||||||
|
await set_admin_ids(database, ["user-kept"])
|
||||||
|
seeded = await seed_setup_admin(
|
||||||
|
database,
|
||||||
|
stored_admin_ids=["user-a", "user-b"],
|
||||||
|
context_user_id="owner-1",
|
||||||
|
)
|
||||||
|
assert seeded == ["user-kept"]
|
||||||
|
assert await list_admin_ids(database) == ["user-kept"]
|
||||||
|
return seeded
|
||||||
|
|
||||||
|
_with_db(tmp_path, "seed-narrowed.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_with_nothing_available_leaves_empty(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
seeded = await seed_setup_admin(
|
||||||
|
database, stored_admin_ids=None, context_user_id=None
|
||||||
|
)
|
||||||
|
assert seeded == []
|
||||||
|
return seeded
|
||||||
|
|
||||||
|
_with_db(tmp_path, "seed-nothing.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_rejects_malformed_candidates(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await seed_setup_admin(
|
||||||
|
database, stored_admin_ids=[42], context_user_id=None
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await seed_setup_admin(
|
||||||
|
database, stored_admin_ids=None, context_user_id=" "
|
||||||
|
)
|
||||||
|
assert await list_admin_ids(database) == []
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "seed-malformed.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Setup wiring: async_setup_entry seeds automatically
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_entry(hass, entry, registry):
|
||||||
|
from tests.test_lifecycle import _wire
|
||||||
|
|
||||||
|
entry = _wire(entry, registry)
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_seeds_allowlist_from_entry_context(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
entry = await _setup_entry(
|
||||||
|
hass,
|
||||||
|
make_entry(context={"user_id": "ha-owner"}),
|
||||||
|
hass.registry,
|
||||||
|
)
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
try:
|
||||||
|
assert await list_admin_ids(database) == ["ha-owner"]
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_seeds_allowlist_from_stored_entry_data(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""A lost database re-seeded from the entry's stored admin copy."""
|
||||||
|
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||||
|
|
||||||
|
entry = await _setup_entry(
|
||||||
|
hass,
|
||||||
|
make_entry(data={CONF_ADMIN_USER_IDS: ["user-a", "user-b"]}),
|
||||||
|
hass.registry,
|
||||||
|
)
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
try:
|
||||||
|
assert await list_admin_ids(database) == ["user-a", "user-b"]
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_without_context_leaves_allowlist_empty(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""Fail closed: no context user and no stored copy means an empty
|
||||||
|
allowlist — nobody is admin until the owner configures it."""
|
||||||
|
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
try:
|
||||||
|
assert await list_admin_ids(database) == []
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_does_not_reseed_narrowed_allowlist(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
from custom_components.nestquest import async_unload_entry
|
||||||
|
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||||
|
|
||||||
|
entry = await _setup_entry(
|
||||||
|
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
|
||||||
|
)
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
await set_admin_ids(database, ["user-parent"])
|
||||||
|
await database.close()
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
|
||||||
|
# Reload with the SAME context: the narrowed list survives.
|
||||||
|
entry_reloaded = await _setup_entry(
|
||||||
|
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
|
||||||
|
)
|
||||||
|
database_b = entry_reloaded.runtime_data.database
|
||||||
|
try:
|
||||||
|
assert await list_admin_ids(database_b) == ["user-parent"]
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
assert await async_unload_entry(hass, entry_reloaded) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_seed_prefers_options_over_entry_data(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""Seed precedence on a fresh database: the options flow's saved
|
||||||
|
list (the current one) beats the config flow's original copy."""
|
||||||
|
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||||
|
|
||||||
|
entry = await _setup_entry(
|
||||||
|
hass,
|
||||||
|
make_entry(
|
||||||
|
options={CONF_ADMIN_USER_IDS: ["user-current"]},
|
||||||
|
data={CONF_ADMIN_USER_IDS: ["ha-owner"]},
|
||||||
|
context={"user_id": "ha-owner"},
|
||||||
|
),
|
||||||
|
hass.registry,
|
||||||
|
)
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
try:
|
||||||
|
assert await list_admin_ids(database) == ["user-current"]
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_owner_fallback_seeds_owner_accounts(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""Last-resort seed: with no persisted copy and no flow-context
|
||||||
|
user, HA owner accounts are seeded so first setup is never
|
||||||
|
ownerless (the real flow may lose the context; owners can already
|
||||||
|
do everything in HA)."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
hass.auth.async_get_users = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
SimpleNamespace(id="owner-1", is_owner=True),
|
||||||
|
SimpleNamespace(id="member-1", is_owner=False),
|
||||||
|
SimpleNamespace(id="owner-2", is_owner=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
try:
|
||||||
|
assert sorted(await list_admin_ids(database)) == ["owner-1", "owner-2"]
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_owner_fallback_not_used_when_context_present(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""The context user (flow caller) wins over the owner fallback:
|
||||||
|
a non-owner parent configuring the integration becomes the admin."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
hass.auth.async_get_users = AsyncMock(
|
||||||
|
return_value=[SimpleNamespace(id="owner-1", is_owner=True)]
|
||||||
|
)
|
||||||
|
entry = await _setup_entry(
|
||||||
|
hass,
|
||||||
|
make_entry(context={"user_id": "parent-configured"}),
|
||||||
|
hass.registry,
|
||||||
|
)
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
try:
|
||||||
|
assert await list_admin_ids(database) == ["parent-configured"]
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_owner_ids_argument(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
# Malformed owner lists are rejected like any other candidate —
|
||||||
|
# while the allowlist is still empty, so the seed proceeds far
|
||||||
|
# enough to validate.
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await seed_setup_admin(
|
||||||
|
database,
|
||||||
|
stored_admin_ids=None,
|
||||||
|
context_user_id=None,
|
||||||
|
owner_ids=[7],
|
||||||
|
)
|
||||||
|
assert await list_admin_ids(database) == []
|
||||||
|
seeded = await seed_setup_admin(
|
||||||
|
database,
|
||||||
|
stored_admin_ids=None,
|
||||||
|
context_user_id=None,
|
||||||
|
owner_ids=["owner-1"],
|
||||||
|
)
|
||||||
|
assert seeded == ["owner-1"]
|
||||||
|
return seeded
|
||||||
|
|
||||||
|
_with_db(tmp_path, "seed-owner-arg.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reopened_setup_failure_closes_database(
|
||||||
|
hass, make_entry, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""A seeding failure on the reopened-record path closes the just-
|
||||||
|
opened connection before re-raising (no leaked handles across HA's
|
||||||
|
setup retries)."""
|
||||||
|
import pytest as pytest_module
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import custom_components.nestquest as nestquest_module
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
|
||||||
|
# First: a successful setup to create the runtime record and file.
|
||||||
|
entry = await _setup_entry(hass, make_entry(), hass.registry)
|
||||||
|
await async_unload_entry(hass, entry)
|
||||||
|
|
||||||
|
# Now a re-setup whose seeding fails after the reopen.
|
||||||
|
async def _failing_seed(database, **kwargs):
|
||||||
|
raise RuntimeError("seed boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
nestquest_module, "seed_setup_admin", _failing_seed, raising=True
|
||||||
|
)
|
||||||
|
|
||||||
|
close_calls: list[bool] = []
|
||||||
|
original_close = NestQuestDatabase.close
|
||||||
|
|
||||||
|
async def _spy_close(self):
|
||||||
|
close_calls.append(True)
|
||||||
|
return await original_close(self)
|
||||||
|
|
||||||
|
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
|
||||||
|
|
||||||
|
with pytest_module.raises(RuntimeError, match="seed boom"):
|
||||||
|
await _setup_entry(hass, entry, hass.registry)
|
||||||
|
# The reopen path opened AND closed a connection; the failure is
|
||||||
|
# the last thing observed.
|
||||||
|
assert close_calls[-1] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# is_admin: the fail-closed resolver (Feature 09's verdict function)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_admin_verdict_matrix(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
from custom_components.nestquest.admin_allowlist import is_admin
|
||||||
|
|
||||||
|
await set_admin_ids(database, ["user-owner"])
|
||||||
|
assert await is_admin(database, "user-owner") is True
|
||||||
|
assert await is_admin(database, "user-other") is False
|
||||||
|
# Unknown, malformed, and absent identities all fail closed.
|
||||||
|
assert await is_admin(database, "") is False
|
||||||
|
assert await is_admin(database, " user-owner ") is False, (
|
||||||
|
"ids are stored trimmed; an untrimmed lookup is a different "
|
||||||
|
"id and must not match"
|
||||||
|
)
|
||||||
|
assert await is_admin(database, None) is False
|
||||||
|
assert await is_admin(database, 42) is False
|
||||||
|
assert await is_admin(database, ["user-owner"]) is False
|
||||||
|
assert await is_admin(database, True) is False
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "resolver-matrix.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_admin_empty_allowlist_fails_closed(tmp_path) -> None:
|
||||||
|
"""An empty allowlist returns False for EVERYONE, not True."""
|
||||||
|
async def _body(database):
|
||||||
|
from custom_components.nestquest.admin_allowlist import is_admin
|
||||||
|
|
||||||
|
for candidate in ("user-owner", "", None, 1):
|
||||||
|
assert await is_admin(database, candidate) is False
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "resolver-empty.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_admin_false_after_last_admin_removal_attempt(tmp_path) -> None:
|
||||||
|
"""The refusal keeps the verdict sound: a refused removal leaves
|
||||||
|
the admin in place, so is_admin still resolves True."""
|
||||||
|
async def _body(database):
|
||||||
|
from custom_components.nestquest.admin_allowlist import is_admin
|
||||||
|
|
||||||
|
await set_admin_ids(database, ["user-owner"])
|
||||||
|
with pytest.raises(ValueError, match="at least one admin"):
|
||||||
|
await set_admin_ids(database, [])
|
||||||
|
assert await is_admin(database, "user-owner") is True
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "resolver-refusal.db")(_body)
|
||||||
@@ -0,0 +1,842 @@
|
|||||||
|
"""Tests for children.py: the Feature 03 child-profile business layer."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.nestquest.children import (
|
||||||
|
create_child,
|
||||||
|
edit_child,
|
||||||
|
list_children,
|
||||||
|
reorder_children,
|
||||||
|
set_child_active,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
from custom_components.nestquest.migrations import apply_migrations
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
return asyncio.new_event_loop().run_until_complete(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_hass_mock():
|
||||||
|
hass = MagicMock()
|
||||||
|
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||||
|
return hass
|
||||||
|
|
||||||
|
|
||||||
|
def _with_db(tmp_path, name):
|
||||||
|
def _run_test(body):
|
||||||
|
async def _main():
|
||||||
|
database = NestQuestDatabase(_make_hass_mock())
|
||||||
|
await database.open(tmp_path / name)
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
return await body(database)
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
return _run(_main())
|
||||||
|
|
||||||
|
return _run_test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_child: required, trimmed display name
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_returns_typed_record(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(
|
||||||
|
database,
|
||||||
|
" Declan ",
|
||||||
|
colour="#2E7D32",
|
||||||
|
avatar_ref="avatars/declan.png",
|
||||||
|
sort_order=2,
|
||||||
|
)
|
||||||
|
assert isinstance(child, ChildRecord)
|
||||||
|
# The name is trimmed before storage.
|
||||||
|
assert child.display_name == "Declan"
|
||||||
|
assert child.colour == "#2E7D32"
|
||||||
|
assert child.avatar_ref == "avatars/declan.png"
|
||||||
|
assert child.sort_order == 2
|
||||||
|
assert child.is_active is True
|
||||||
|
# The business layer stamps created_at in strict UTC shape.
|
||||||
|
assert re.fullmatch(
|
||||||
|
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", child.created_at
|
||||||
|
)
|
||||||
|
return child
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-typed.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_defaults(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Chloe")
|
||||||
|
assert child.display_name == "Chloe"
|
||||||
|
assert child.colour is None
|
||||||
|
assert child.avatar_ref is None
|
||||||
|
assert child.sort_order == 0
|
||||||
|
return child
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-defaults.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["", " ", "\t\n", None])
|
||||||
|
def test_create_child_missing_name_rejected(tmp_path, bad) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="display_name is required"):
|
||||||
|
await create_child(database, bad)
|
||||||
|
assert await list_children(database) == []
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-empty-name.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [42, ["Ada"], 3.5])
|
||||||
|
def test_create_child_non_string_name_rejected(tmp_path, bad) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="must be a string"):
|
||||||
|
await create_child(database, bad)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-bad-name-type.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_optional_text_fields_validated(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="colour must be a string"):
|
||||||
|
await create_child(database, "Ada", colour=7)
|
||||||
|
with pytest.raises(ValueError, match="avatar_ref must be a string"):
|
||||||
|
await create_child(database, "Ada", avatar_ref=1.5)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-bad-optional.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["2", 1.5, True, None])
|
||||||
|
def test_create_child_sort_order_must_be_int(tmp_path, bad) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="sort_order must be an integer"):
|
||||||
|
await create_child(database, "Ada", sort_order=bad)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-bad-order.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# create_child: duplicate display name allowed but warned
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_duplicate_name_allowed_and_warned(
|
||||||
|
tmp_path, caplog
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada")
|
||||||
|
with caplog.at_level(
|
||||||
|
logging.WARNING, logger="custom_components.nestquest.children"
|
||||||
|
):
|
||||||
|
second = await create_child(database, " ADA ")
|
||||||
|
# Whitespace/case-normalised duplicate: allowed, both rows exist.
|
||||||
|
assert first.display_name == "Ada"
|
||||||
|
assert second.display_name == "ADA"
|
||||||
|
names = [c.display_name for c in await list_children(database)]
|
||||||
|
assert names == ["Ada", "ADA"]
|
||||||
|
warnings = [
|
||||||
|
r for r in caplog.records if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert len(warnings) == 1, (
|
||||||
|
"exactly one duplicate warning for the second create"
|
||||||
|
)
|
||||||
|
assert "Ada" in warnings[0].getMessage()
|
||||||
|
return second
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-duplicate.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_distinct_name_logs_no_warning(
|
||||||
|
tmp_path, caplog
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
await create_child(database, "Ada")
|
||||||
|
caplog.clear()
|
||||||
|
await create_child(database, "Jordyn")
|
||||||
|
warnings = [
|
||||||
|
r for r in caplog.records if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert warnings == []
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "create-no-duplicate.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# edit_child: returns the updated record
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_updates_fields_and_returns_record(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada", colour="#111111")
|
||||||
|
edited = await edit_child(
|
||||||
|
database,
|
||||||
|
child.id,
|
||||||
|
display_name=" Ada L. ",
|
||||||
|
colour="#222222",
|
||||||
|
avatar_ref="avatars/ada.png",
|
||||||
|
sort_order=3,
|
||||||
|
)
|
||||||
|
assert isinstance(edited, ChildRecord)
|
||||||
|
assert edited.id == child.id
|
||||||
|
assert edited.display_name == "Ada L."
|
||||||
|
assert edited.colour == "#222222"
|
||||||
|
assert edited.avatar_ref == "avatars/ada.png"
|
||||||
|
assert edited.sort_order == 3
|
||||||
|
# The stored row reflects every edit.
|
||||||
|
stored = await list_children(database)
|
||||||
|
assert len(stored) == 1
|
||||||
|
assert stored[0] == edited
|
||||||
|
return edited
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-round-trip.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_omitted_fields_unchanged(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(
|
||||||
|
database, "Ada", colour="#111111", sort_order=5
|
||||||
|
)
|
||||||
|
edited = await edit_child(database, child.id, sort_order=9)
|
||||||
|
# Only sort_order moved; colour and name untouched.
|
||||||
|
assert edited.sort_order == 9
|
||||||
|
assert edited.colour == "#111111"
|
||||||
|
assert edited.display_name == "Ada"
|
||||||
|
return edited
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-partial.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_no_fields_returns_current_record(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada", colour="#111111")
|
||||||
|
edited = await edit_child(database, child.id)
|
||||||
|
assert edited == child
|
||||||
|
return edited
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-noop.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_missing_raises(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="does not exist"):
|
||||||
|
await edit_child(database, 999, display_name="X")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-missing.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [True, False, 1.0, "1", None])
|
||||||
|
def test_edit_child_non_int_id_rejected(tmp_path, bad) -> None:
|
||||||
|
"""SQLite binds bools as integers: True must not address child 1,
|
||||||
|
and floats must not round onto a real profile."""
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
await edit_child(database, bad, display_name="X")
|
||||||
|
# The refused edit left the profile untouched.
|
||||||
|
assert (await list_children(database))[0].display_name == "Ada"
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-bad-id.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [True, False, 1.0, "1", None])
|
||||||
|
def test_set_child_active_non_int_id_rejected(tmp_path, bad) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
await set_child_active(database, bad, False)
|
||||||
|
assert (await list_children(database))[0].is_active is True
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-active-bad-id.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_name_validation(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
for bad in ("", " ", None, 42):
|
||||||
|
with pytest.raises(ValueError, match="display_name"):
|
||||||
|
await edit_child(database, child.id, display_name=bad)
|
||||||
|
# The failed edits left the record untouched.
|
||||||
|
assert (await list_children(database))[0].display_name == "Ada"
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-name-validation.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_sort_order_must_be_int(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
with pytest.raises(ValueError, match="sort_order must be an integer"):
|
||||||
|
await edit_child(database, child.id, sort_order="3")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-bad-order.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_explicit_none_clear_rejected(tmp_path) -> None:
|
||||||
|
"""Clearing is refused loudly, not silently ignored: the DAO cannot
|
||||||
|
express NULL writes, so an explicit None must never masquerade as a
|
||||||
|
no-op."""
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada", colour="#111111")
|
||||||
|
with pytest.raises(ValueError, match="clearing colour"):
|
||||||
|
await edit_child(database, child.id, colour=None)
|
||||||
|
with pytest.raises(ValueError, match="clearing avatar_ref"):
|
||||||
|
await edit_child(database, child.id, avatar_ref=None)
|
||||||
|
# The refused clears left the colour intact.
|
||||||
|
assert (await list_children(database))[0].colour == "#111111"
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-clear-unsupported.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_rename_to_duplicate_warns_but_different_name_not(
|
||||||
|
tmp_path, caplog
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
ada = await create_child(database, "Ada")
|
||||||
|
bo = await create_child(database, "Bo")
|
||||||
|
caplog.clear()
|
||||||
|
# Renaming Bo onto Ada's name warns (different child's name).
|
||||||
|
edited = await edit_child(database, bo.id, display_name="ada")
|
||||||
|
assert edited.display_name == "ada"
|
||||||
|
warnings = [
|
||||||
|
r for r in caplog.records if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert len(warnings) == 1
|
||||||
|
assert "Ada" in warnings[0].getMessage()
|
||||||
|
return edited
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-rename-duplicate.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_rename_to_own_name_casing_alone_warns_not(
|
||||||
|
tmp_path, caplog
|
||||||
|
) -> None:
|
||||||
|
"""Renaming a child to their own name (casing aside) is not a
|
||||||
|
duplicate when NO OTHER child carries the normalised name."""
|
||||||
|
async def _body(database):
|
||||||
|
ada = await create_child(database, "Ada")
|
||||||
|
caplog.clear()
|
||||||
|
edited = await edit_child(database, ada.id, display_name="ADA")
|
||||||
|
assert edited.display_name == "ADA"
|
||||||
|
assert not any(
|
||||||
|
"duplicates existing child" in r.getMessage()
|
||||||
|
for r in caplog.records
|
||||||
|
)
|
||||||
|
return edited
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-rename-self.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None:
|
||||||
|
"""Racing creates of the same normalised name cannot slip through
|
||||||
|
unlogged: the scan and INSERT are serialized on the connection
|
||||||
|
lock, so the second create sees the first's committed row and
|
||||||
|
warns. Deterministic gate on task A's INSERT while task B queues
|
||||||
|
behind the lock. Without the lock, B's scan would run before A's
|
||||||
|
INSERT commits and no warning would fire."""
|
||||||
|
async def _main():
|
||||||
|
armed = {"active": False, "gated": False}
|
||||||
|
gate_open = asyncio.Event()
|
||||||
|
a_started = asyncio.Event()
|
||||||
|
hass = _gated_hass(armed, gate_open, a_started)
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "create-race.db")
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
logger = logging.getLogger(
|
||||||
|
"custom_components.nestquest.children"
|
||||||
|
)
|
||||||
|
seen: list[logging.LogRecord] = []
|
||||||
|
|
||||||
|
class _Capture(logging.Handler):
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
seen.append(record)
|
||||||
|
|
||||||
|
handler = _Capture()
|
||||||
|
logger.addHandler(handler)
|
||||||
|
try:
|
||||||
|
armed["active"] = True
|
||||||
|
task_a = asyncio.ensure_future(
|
||||||
|
create_child(database, "Ada")
|
||||||
|
)
|
||||||
|
await a_started.wait()
|
||||||
|
armed["active"] = False
|
||||||
|
task_b = asyncio.ensure_future(
|
||||||
|
create_child(database, " ada ")
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
gate_open.set()
|
||||||
|
first, second = await asyncio.gather(task_a, task_b)
|
||||||
|
finally:
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
|
||||||
|
# Both creates land; the duplicate is allowed.
|
||||||
|
assert first.display_name == "Ada"
|
||||||
|
assert second.display_name == "ada"
|
||||||
|
names = [c.display_name for c in await list_children(database)]
|
||||||
|
assert names == ["Ada", "ada"]
|
||||||
|
# Exactly one warning: B's scan saw A's committed row.
|
||||||
|
warnings = [
|
||||||
|
r for r in seen if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert len(warnings) == 1, (
|
||||||
|
"the queued duplicate create must warn exactly once"
|
||||||
|
)
|
||||||
|
assert "Ada" in warnings[0].getMessage()
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
_run(_main())
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_case_only_edit_colliding_with_other_child_warns(
|
||||||
|
tmp_path, caplog
|
||||||
|
) -> None:
|
||||||
|
"""A case-only edit must not hide a real duplicate: with two Ada
|
||||||
|
profiles already present, editing either one's name to another
|
||||||
|
casing still warns (the scan excludes only the edited child)."""
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada")
|
||||||
|
await create_child(database, "ada")
|
||||||
|
caplog.clear()
|
||||||
|
edited = await edit_child(database, first.id, display_name="ADA")
|
||||||
|
assert edited.display_name == "ADA"
|
||||||
|
warnings = [
|
||||||
|
r for r in caplog.records if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert len(warnings) == 1
|
||||||
|
# Editing the name to a value that STAYS duplicated (no change,
|
||||||
|
# other child still carries it) warns too: the scan is on the
|
||||||
|
# resulting state, not on whether the word moved.
|
||||||
|
caplog.clear()
|
||||||
|
await edit_child(database, first.id, display_name=" ada ")
|
||||||
|
warnings = [
|
||||||
|
r for r in caplog.records if "duplicates existing child" in r.getMessage()
|
||||||
|
]
|
||||||
|
assert len(warnings) == 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-case-only-duplicate.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# set_child_active / list_children
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_child_active_round_trip(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
deactivated = await set_child_active(database, child.id, False)
|
||||||
|
assert deactivated.is_active is False
|
||||||
|
assert [c.id for c in await list_children(database)] == [child.id]
|
||||||
|
assert await list_children(database, active_only=True) == []
|
||||||
|
reactivated = await set_child_active(database, child.id, True)
|
||||||
|
assert reactivated.is_active is True
|
||||||
|
assert len(await list_children(database, active_only=True)) == 1
|
||||||
|
return reactivated
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-active.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_child_active_missing_raises(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
with pytest.raises(ValueError, match="does not exist"):
|
||||||
|
await set_child_active(database, 999, False)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-active-missing.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# reorder_children: complete permutation or nothing changes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_persists_display_order(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
third = await create_child(database, "Cleo", sort_order=2)
|
||||||
|
await reorder_children(database, [third.id, first.id, second.id])
|
||||||
|
# The active list returns children in the new sort order.
|
||||||
|
listed = await list_children(database, active_only=True)
|
||||||
|
assert [c.id for c in listed] == [third.id, first.id, second.id]
|
||||||
|
assert [c.sort_order for c in listed] == [0, 1, 2]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-round-trip.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_partial_list_rejected_order_unchanged(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
third = await create_child(database, "Cleo", sort_order=2)
|
||||||
|
with pytest.raises(ValueError, match="missing child ids"):
|
||||||
|
await reorder_children(database, [third.id, first.id])
|
||||||
|
listed = await list_children(database)
|
||||||
|
# Nothing changed: rejection happens before any write.
|
||||||
|
assert [c.id for c in listed] == [first.id, second.id, third.id]
|
||||||
|
assert [c.sort_order for c in listed] == [0, 1, 2]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-partial.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_unknown_id_rejected_order_unchanged(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
with pytest.raises(ValueError, match="unknown child ids"):
|
||||||
|
await reorder_children(database, [second.id, first.id, 999])
|
||||||
|
listed = await list_children(database)
|
||||||
|
assert [c.id for c in listed] == [first.id, second.id]
|
||||||
|
assert [c.sort_order for c in listed] == [0, 1]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-unknown.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_empty_list_rejected_when_children_exist(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""An empty list is the maximally partial reorder: rejected while
|
||||||
|
any child exists (against an empty table it is a complete
|
||||||
|
permutation and stays a harmless no-op)."""
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
with pytest.raises(ValueError, match="missing child ids"):
|
||||||
|
await reorder_children(database, [])
|
||||||
|
assert [c.sort_order for c in await list_children(database)] == [0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-empty.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_duplicate_and_non_int_rejected(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
with pytest.raises(ValueError, match="duplicate child ids"):
|
||||||
|
await reorder_children(database, [first.id, first.id, second.id])
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
await reorder_children(database, [first.id, second.id, True])
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
await reorder_children(database, [first.id, second.id, "3"])
|
||||||
|
listed = await list_children(database)
|
||||||
|
assert [c.sort_order for c in listed] == [0, 1]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-bad-inputs.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_inactive_children_included(tmp_path) -> None:
|
||||||
|
"""Inactive children keep their display position: the permutation
|
||||||
|
covers the whole table, not just the active list."""
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
await set_child_active(database, second.id, False)
|
||||||
|
# Both ids are required even though one is inactive.
|
||||||
|
await reorder_children(database, [second.id, first.id])
|
||||||
|
listed = await list_children(database)
|
||||||
|
assert [c.id for c in listed] == [second.id, first.id]
|
||||||
|
assert [c.sort_order for c in listed] == [0, 1]
|
||||||
|
# The active list skips the inactive child but keeps position.
|
||||||
|
assert [c.id for c in await list_children(database, active_only=True)] == [
|
||||||
|
first.id
|
||||||
|
]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-inactive.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["1", "0", 1, 0, 1.0, None, "true"])
|
||||||
|
def test_set_child_active_rejects_non_bool(tmp_path, bad) -> None:
|
||||||
|
"""The DAO's int() would silently coerce "1"/0/1.0 into a state the
|
||||||
|
caller never asked for; the business layer refuses non-bools."""
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
with pytest.raises(ValueError, match="is_active must be a real bool"):
|
||||||
|
await set_child_active(database, child.id, bad)
|
||||||
|
# The refused transition left the child untouched.
|
||||||
|
assert (await list_children(database))[0].is_active is True
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-active-non-bool.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Concurrency: edit/set_active are atomic under the connection lock
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _gated_hass(armed: dict, gate_open: asyncio.Event, a_started: asyncio.Event):
|
||||||
|
"""A hass mock whose executor gates the FIRST `_execute` job after
|
||||||
|
arming — the mutate statement of whichever business call starts
|
||||||
|
first — proving the caller holds the connection lock at that
|
||||||
|
moment and the second caller must queue behind it."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
hass = MagicMock()
|
||||||
|
|
||||||
|
async def _gated_executor(fn, *args):
|
||||||
|
if armed["active"]:
|
||||||
|
if getattr(fn, "__name__", "") == "_execute" and not armed["gated"]:
|
||||||
|
armed["gated"] = True
|
||||||
|
a_started.set()
|
||||||
|
await gate_open.wait()
|
||||||
|
return fn(*args)
|
||||||
|
|
||||||
|
hass.async_add_executor_job = _gated_executor
|
||||||
|
return hass
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_child_concurrent_opposite_edits_return_own_values(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""Two racing edits cannot cross-contaminate each other's readback.
|
||||||
|
|
||||||
|
Deterministic gate: task A is paused at its UPDATE (it provably
|
||||||
|
holds the connection lock), task B is then created and must queue;
|
||||||
|
A's readback runs before B's edit, so A returns A's value and B
|
||||||
|
returns B's. Without the lock, A's readback would see B's write.
|
||||||
|
"""
|
||||||
|
async def _main():
|
||||||
|
armed = {"active": False, "gated": False}
|
||||||
|
gate_open = asyncio.Event()
|
||||||
|
a_started = asyncio.Event()
|
||||||
|
hass = _gated_hass(armed, gate_open, a_started)
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "edit-race.db")
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
child = await create_child(
|
||||||
|
database, "Ada", colour="#111111", sort_order=0
|
||||||
|
)
|
||||||
|
|
||||||
|
armed["active"] = True
|
||||||
|
task_a = asyncio.ensure_future(
|
||||||
|
edit_child(database, child.id, colour="#AAAAAA", sort_order=1)
|
||||||
|
)
|
||||||
|
await a_started.wait()
|
||||||
|
armed["active"] = False
|
||||||
|
task_b = asyncio.ensure_future(
|
||||||
|
edit_child(
|
||||||
|
database, child.id, colour="#BBBBBB", sort_order=2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
gate_open.set()
|
||||||
|
record_a, record_b = await asyncio.gather(task_a, task_b)
|
||||||
|
|
||||||
|
# Each caller gets back exactly what it wrote.
|
||||||
|
assert record_a.colour == "#AAAAAA"
|
||||||
|
assert record_a.sort_order == 1
|
||||||
|
assert record_b.colour == "#BBBBBB"
|
||||||
|
assert record_b.sort_order == 2
|
||||||
|
# B landed last: the stored row is B's (read via the DAO —
|
||||||
|
# the children leak guard forbids raw probes here).
|
||||||
|
stored = await ChildrenDao(database).get(child.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert (stored.colour, stored.sort_order) == ("#BBBBBB", 2)
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
_run(_main())
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_child_active_concurrent_opposite_transitions_return_own(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""Racing activate/deactivate calls each report their own request.
|
||||||
|
|
||||||
|
Same deterministic gate on task A's UPDATE; task B queues behind
|
||||||
|
the connection lock and lands the opposite transition after A.
|
||||||
|
"""
|
||||||
|
async def _main():
|
||||||
|
armed = {"active": False, "gated": False}
|
||||||
|
gate_open = asyncio.Event()
|
||||||
|
a_started = asyncio.Event()
|
||||||
|
hass = _gated_hass(armed, gate_open, a_started)
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "active-race.db")
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
|
||||||
|
armed["active"] = True
|
||||||
|
task_a = asyncio.ensure_future(
|
||||||
|
set_child_active(database, child.id, False)
|
||||||
|
)
|
||||||
|
await a_started.wait()
|
||||||
|
armed["active"] = False
|
||||||
|
task_b = asyncio.ensure_future(
|
||||||
|
set_child_active(database, child.id, True)
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
gate_open.set()
|
||||||
|
record_a, record_b = await asyncio.gather(task_a, task_b)
|
||||||
|
|
||||||
|
assert record_a.is_active is False
|
||||||
|
assert record_b.is_active is True
|
||||||
|
stored = await ChildrenDao(database).get(child.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.is_active is True
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
_run(_main())
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_children_orders_by_sort_order_then_id(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
third = await create_child(database, "Cleo", sort_order=1)
|
||||||
|
listed = await list_children(database)
|
||||||
|
# sort_order 0 first, then the two sort_order-1 rows by id.
|
||||||
|
assert [c.id for c in listed] == [first.id, second.id, third.id]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "list-order.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deactivation keeps every referencing row intact (Feature 03)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_deactivating_child_preserves_definitions_instances_events(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
|
from custom_components.nestquest.dao_instances import (
|
||||||
|
CompletionEventsDao,
|
||||||
|
QuestInstancesDao,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.dao_rules import (
|
||||||
|
QuestDefinitionsDao,
|
||||||
|
ScheduleRulesDao,
|
||||||
|
)
|
||||||
|
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
rules = ScheduleRulesDao(database)
|
||||||
|
definitions = QuestDefinitionsDao(database)
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
events = CompletionEventsDao(database)
|
||||||
|
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
rule = await rules.create("daily", "2026-01-01")
|
||||||
|
definition = await definitions.create(
|
||||||
|
"Brush teeth",
|
||||||
|
rule.id,
|
||||||
|
"2026-09-15T12:00:00+00:00",
|
||||||
|
assignee_child_ids=[child.id],
|
||||||
|
)
|
||||||
|
due = (date.today() + timedelta(days=1)).isoformat()
|
||||||
|
stamp = f"{due}T08:00:00+00:00"
|
||||||
|
instance = await instances.upsert(
|
||||||
|
definition.id, child.id, due, stamp, window="morning"
|
||||||
|
)
|
||||||
|
event = await events.append(
|
||||||
|
instance.id,
|
||||||
|
child.id,
|
||||||
|
"completed",
|
||||||
|
"panel",
|
||||||
|
f"{due}T09:00:00+00:00",
|
||||||
|
True,
|
||||||
|
actor_child_id=child.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deactivate: the referencing rows must not move.
|
||||||
|
deactivated = await set_child_active(database, child.id, False)
|
||||||
|
assert deactivated.is_active is False
|
||||||
|
assert await children.get(child.id) == deactivated
|
||||||
|
assert (await definitions.get(definition.id)) == definition
|
||||||
|
assert (await instances.get_by_id(instance.id)) == instance
|
||||||
|
assert await events.list_by_instance(instance.id) == [event]
|
||||||
|
# Excluded from the active list, still present in the full list.
|
||||||
|
assert await list_children(database, active_only=True) == []
|
||||||
|
assert [c.id for c in await list_children(database)] == [child.id]
|
||||||
|
|
||||||
|
# Reactivate: fully restored.
|
||||||
|
reactivated = await set_child_active(database, child.id, True)
|
||||||
|
assert reactivated.is_active is True
|
||||||
|
assert [c.id for c in await list_children(database, active_only=True)] == [
|
||||||
|
child.id
|
||||||
|
]
|
||||||
|
assert (await instances.get_by_id(instance.id)) == instance
|
||||||
|
return reactivated
|
||||||
|
|
||||||
|
_with_db(tmp_path, "deactivate-data-intact.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_code_path_deletes_a_child_row() -> None:
|
||||||
|
"""Structural guardrail: children are deactivated, never deleted.
|
||||||
|
|
||||||
|
The DAO exposes no delete method at all, and no module in the
|
||||||
|
package (nor any test outside the sanctioned DDL/migration layers,
|
||||||
|
which create the tables) carries a statement that removes child
|
||||||
|
rows — the leak guard in test_dao_children enforces the file
|
||||||
|
allowlist, this asserts the no-delete property itself across every
|
||||||
|
file that may legitimately name the table.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
package = Path(
|
||||||
|
__import__(
|
||||||
|
"custom_components.nestquest", fromlist=["__file__"]
|
||||||
|
).__file__
|
||||||
|
).parent
|
||||||
|
|
||||||
|
delete_pattern = re.compile(
|
||||||
|
r"DELETE\s+(FROM\s+)?((main|temp)\s*\.\s*)?[`'\"]*(\[)?children\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
for py in sorted(package.rglob("*.py")):
|
||||||
|
assert delete_pattern.search(py.read_text()) is None, (
|
||||||
|
f"a code path in {py.name} deletes child rows"
|
||||||
|
)
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
|
|
||||||
|
methods = {
|
||||||
|
name
|
||||||
|
for name, _ in inspect.getmembers(ChildrenDao, inspect.isfunction)
|
||||||
|
}
|
||||||
|
assert not any(
|
||||||
|
name.startswith("delete") or name == "remove" for name in methods
|
||||||
|
), f"ChildrenDao exposed a delete-shaped method: {methods}"
|
||||||
@@ -155,3 +155,24 @@ def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
|
|||||||
assert node.value != "nestquest.db", (
|
assert node.value != "nestquest.db", (
|
||||||
f"Found hard-coded database filename in {py_file.name}:{node.lineno}"
|
f"Found hard-coded database filename in {py_file.name}:{node.lineno}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_flow_persists_flow_user_as_initial_admin(hass, make_flow) -> None:
|
||||||
|
"""The HA user completing the flow lands in entry data as the
|
||||||
|
initial admin copy: entry.context is not persisted by real HA, so
|
||||||
|
the durable record must carry the owner."""
|
||||||
|
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
|
||||||
|
|
||||||
|
flow = make_flow(hass)
|
||||||
|
flow.context = {"user_id": "ha-owner"}
|
||||||
|
result = await flow.async_step_user({})
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"] == {CONF_ADMIN_USER_IDS: ["ha-owner"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_without_context_user_keeps_data_untouched(
|
||||||
|
hass, make_flow
|
||||||
|
) -> None:
|
||||||
|
flow = make_flow(hass)
|
||||||
|
result = await flow.async_step_user({"horizon_days": 7})
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"] == {"horizon_days": 7}
|
||||||
|
|||||||
@@ -227,24 +227,34 @@ def test_reorder_is_transactional_and_idempotent(tmp_path) -> None:
|
|||||||
_run(database.close())
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
def test_reorder_empty_list_is_noop(tmp_path) -> None:
|
def test_reorder_empty_list_is_rejected(tmp_path) -> None:
|
||||||
|
"""An empty list is the maximally partial reorder: rejected while
|
||||||
|
any child exists (Feature 03 reorder contract)."""
|
||||||
|
import pytest as pytest_module
|
||||||
|
|
||||||
database = _open_db(tmp_path / "reorder-empty.db")
|
database = _open_db(tmp_path / "reorder-empty.db")
|
||||||
try:
|
try:
|
||||||
dao, _ = _daos(database)
|
dao, _ = _daos(database)
|
||||||
_make_child(dao, "Ada")
|
_make_child(dao, "Ada")
|
||||||
_run(dao.reorder([]))
|
with pytest_module.raises(ValueError, match="missing child ids"):
|
||||||
|
_run(dao.reorder([]))
|
||||||
children = _run(dao.list_all())
|
children = _run(dao.list_all())
|
||||||
assert children[0].sort_order == 0
|
assert children[0].sort_order == 0
|
||||||
finally:
|
finally:
|
||||||
_run(database.close())
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
def test_reorder_unknown_id_leaves_it_untouched(tmp_path) -> None:
|
def test_reorder_unknown_id_is_rejected(tmp_path) -> None:
|
||||||
|
"""An unknown id is rejected before any write; the order is
|
||||||
|
untouched (Feature 03 reorder contract)."""
|
||||||
|
import pytest as pytest_module
|
||||||
|
|
||||||
database = _open_db(tmp_path / "reorder-unknown.db")
|
database = _open_db(tmp_path / "reorder-unknown.db")
|
||||||
try:
|
try:
|
||||||
dao, _ = _daos(database)
|
dao, _ = _daos(database)
|
||||||
a = _make_child(dao, "Ada")
|
a = _make_child(dao, "Ada")
|
||||||
_run(dao.reorder([a.id, 999]))
|
with pytest_module.raises(ValueError, match="unknown child ids"):
|
||||||
|
_run(dao.reorder([a.id, 999]))
|
||||||
children = _run(dao.list_all())
|
children = _run(dao.list_all())
|
||||||
assert children[0].sort_order == 0
|
assert children[0].sort_order == 0
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+36
-10
@@ -664,6 +664,7 @@ def test_append_panel_event_round_trips(tmp_path) -> None:
|
|||||||
definition.id, child.id, D1, _now_stamp(), window="morning")
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
event = await events.append(
|
event = await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), False,
|
instance.id, child.id, "completed", "panel", _now_stamp(), False,
|
||||||
|
actor_child_id=child.id,
|
||||||
actor_user_id=None,
|
actor_user_id=None,
|
||||||
)
|
)
|
||||||
assert event.actor_source == "panel"
|
assert event.actor_source == "panel"
|
||||||
@@ -718,17 +719,31 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None:
|
|||||||
with pytest.raises(ValueError, match="must not carry"):
|
with pytest.raises(ValueError, match="must not carry"):
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), True,
|
instance.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||||
|
actor_child_id=child.id,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
)
|
)
|
||||||
|
with pytest.raises(ValueError, match="requires actor_child_id"):
|
||||||
|
await events.append(
|
||||||
|
instance.id, child.id, "completed", "panel",
|
||||||
|
_now_stamp(), True,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="must not carry actor_child_id"):
|
||||||
|
await events.append(
|
||||||
|
instance.id, child.id, "completed", "user", _now_stamp(), True,
|
||||||
|
actor_user_id="user-1",
|
||||||
|
actor_child_id=child.id,
|
||||||
|
)
|
||||||
with pytest.raises(ValueError, match="requires was_on_time"):
|
with pytest.raises(ValueError, match="requires was_on_time"):
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), None
|
instance.id, child.id, "completed", "panel", _now_stamp(), None,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
# was_on_time must be a real bool: int-convertible junk that
|
# was_on_time must be a real bool: int-convertible junk that
|
||||||
# would silently coerce (0.5 -> False) is rejected outright.
|
# would silently coerce (0.5 -> False) is rejected outright.
|
||||||
with pytest.raises(ValueError, match="must be True, False or None"):
|
with pytest.raises(ValueError, match="must be True, False or None"):
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), 0.5
|
instance.id, child.id, "completed", "panel",
|
||||||
|
_now_stamp(), 0.5, actor_child_id=child.id,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="must be True, False or None"):
|
with pytest.raises(ValueError, match="must be True, False or None"):
|
||||||
await events.append(
|
await events.append(
|
||||||
@@ -758,7 +773,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None:
|
|||||||
with pytest.raises(ValueError, match="UTC"):
|
with pytest.raises(ValueError, match="UTC"):
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", bad,
|
instance.id, child.id, "completed", "panel", bad,
|
||||||
True,
|
True, actor_child_id=child.id,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -815,14 +830,16 @@ def test_completed_uncompleted_recompleted_three_ordered_events(
|
|||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp(), window="morning")
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), True
|
instance.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), False
|
instance.id, child.id, "completed", "panel", _now_stamp(), False,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
history = await events.list_by_instance(instance.id)
|
history = await events.list_by_instance(instance.id)
|
||||||
assert [e.event_type for e in history] == [
|
assert [e.event_type for e in history] == [
|
||||||
@@ -855,7 +872,8 @@ def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None:
|
|||||||
instance = await instances.upsert(
|
instance = await instances.upsert(
|
||||||
definition.id, child.id, D1, _now_stamp(), window="morning")
|
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "completed", "panel", _now_stamp(), True
|
instance.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
||||||
@@ -878,17 +896,20 @@ def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None:
|
|||||||
late = await instances.upsert(
|
late = await instances.upsert(
|
||||||
definition.id, child.id, D20, _now_stamp(), window="morning")
|
definition.id, child.id, D20, _now_stamp(), window="morning")
|
||||||
await events.append(
|
await events.append(
|
||||||
early.id, child.id, "completed", "panel", _now_stamp(), True
|
early.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
inside.id, child.id, "completed", "panel", _now_stamp(), False
|
inside.id, child.id, "completed", "panel", _now_stamp(), False,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
inside.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
inside.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
)
|
)
|
||||||
await events.append(
|
await events.append(
|
||||||
late.id, child.id, "completed", "panel", _now_stamp(), True
|
late.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||||
|
actor_child_id=child.id
|
||||||
)
|
)
|
||||||
found = await events.list_by_child_and_date_range(
|
found = await events.list_by_child_and_date_range(
|
||||||
child.id, D2, D2
|
child.id, D2, D2
|
||||||
@@ -922,7 +943,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None:
|
|||||||
# queried on today's date finds it too.
|
# queried on today's date finds it too.
|
||||||
event = await events.append(
|
event = await events.append(
|
||||||
late_due.id, child.id, "completed", "panel",
|
late_due.id, child.id, "completed", "panel",
|
||||||
_now_stamp(), False,
|
_now_stamp(), False, actor_child_id=child.id,
|
||||||
)
|
)
|
||||||
by_due = await events.list_by_child_and_date_range(
|
by_due = await events.list_by_child_and_date_range(
|
||||||
child.id, D20, D20
|
child.id, D20, D20
|
||||||
@@ -1010,7 +1031,12 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None:
|
|||||||
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
|
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
|
||||||
insert_exempt = {
|
insert_exempt = {
|
||||||
"custom_components/nestquest/dao_instances.py",
|
"custom_components/nestquest/dao_instances.py",
|
||||||
|
"custom_components/nestquest/migrations.py", # the rebuild
|
||||||
|
# migrations re-create the table via INSERT..SELECT (append-
|
||||||
|
# order preserved); UPDATE/DELETE stays forbidden everywhere
|
||||||
"tests/test_schema.py",
|
"tests/test_schema.py",
|
||||||
|
"tests/test_migrations.py", # seeds frozen legacy shapes to
|
||||||
|
# exercise the rebuild migrations
|
||||||
"tests/test_dao_instances.py", # this guard file; scanned by
|
"tests/test_dao_instances.py", # this guard file; scanned by
|
||||||
# its own guard's regex spans below instead
|
# its own guard's regex spans below instead
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,14 +372,16 @@ def test_matrix_completion_events_append_read_and_no_mutation(
|
|||||||
async def _body(w: World):
|
async def _body(w: World):
|
||||||
# insert: append a completion then a reversal, then re-complete
|
# insert: append a completion then a reversal, then re-complete
|
||||||
await w.events.append(
|
await w.events.append(
|
||||||
w.instance.id, w.child.id, "completed", "panel", _stamp(), True
|
w.instance.id, w.child.id, "completed", "panel", _stamp(), True,
|
||||||
|
actor_child_id=w.child.id,
|
||||||
)
|
)
|
||||||
await w.events.append(
|
await w.events.append(
|
||||||
w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None,
|
w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None,
|
||||||
actor_user_id="user-1",
|
actor_user_id="user-1",
|
||||||
)
|
)
|
||||||
await w.events.append(
|
await w.events.append(
|
||||||
w.instance.id, w.child.id, "completed", "panel", _stamp(), False
|
w.instance.id, w.child.id, "completed", "panel", _stamp(), False,
|
||||||
|
actor_child_id=w.child.id,
|
||||||
)
|
)
|
||||||
# read: full ordered history + latest
|
# read: full ordered history + latest
|
||||||
history = await w.events.list_by_instance(w.instance.id)
|
history = await w.events.list_by_instance(w.instance.id)
|
||||||
|
|||||||
@@ -620,3 +620,172 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
|
|||||||
assert sql_pattern.search(remaining) is None, (
|
assert sql_pattern.search(remaining) is None, (
|
||||||
"tests must go through the DAO, not raw SQL, for these tables"
|
"tests must go through the DAO, not raw SQL, for these tables"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Presence overrides: overlap rejection naming the conflict
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_create_round_trip_single_and_range(tmp_path) -> None:
|
||||||
|
async def _body(database, schedules, overrides, children, child):
|
||||||
|
single = await overrides.create(
|
||||||
|
child.id, "2026-07-20", "2026-07-20", False
|
||||||
|
)
|
||||||
|
assert (single.start_date, single.end_date) == ("2026-07-20",) * 2
|
||||||
|
span = await overrides.create(
|
||||||
|
child.id, "2026-08-01", "2026-08-05", True, note="traded week"
|
||||||
|
)
|
||||||
|
assert span.is_present is True
|
||||||
|
assert span.note == "traded week"
|
||||||
|
# Distinct, non-overlapping ranges coexist.
|
||||||
|
listed = await overrides.list_by_child_and_range(
|
||||||
|
child.id, "2026-01-01", "2026-12-31"
|
||||||
|
)
|
||||||
|
assert [row.id for row in listed] == [single.id, span.id]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "override-round-trip.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None:
|
||||||
|
async def _body(database, schedules, overrides, children, child):
|
||||||
|
existing = await overrides.create(
|
||||||
|
child.id, "2026-07-20", "2026-07-24", False, note="holiday"
|
||||||
|
)
|
||||||
|
for start, end in (
|
||||||
|
("2026-07-24", "2026-07-26"), # touches the existing end
|
||||||
|
("2026-07-18", "2026-07-20"), # touches the existing start
|
||||||
|
("2026-07-21", "2026-07-22"), # fully inside
|
||||||
|
("2026-07-01", "2026-07-30"), # fully covering
|
||||||
|
):
|
||||||
|
with pytest.raises(ValueError, match="overlaps existing"):
|
||||||
|
await overrides.create(
|
||||||
|
child.id, start, end, True, note="swap"
|
||||||
|
)
|
||||||
|
# The error NAMES the conflicting override (id + its range).
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match=(
|
||||||
|
rf"override {existing.id} "
|
||||||
|
rf"\[2026-07-20, 2026-07-24\].*is_present=False"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await overrides.create(child.id, "2026-07-22", "2026-07-22", True)
|
||||||
|
# Nothing was written by any rejected attempt.
|
||||||
|
listed = await overrides.list_by_child_and_range(
|
||||||
|
child.id, "2026-01-01", "2026-12-31"
|
||||||
|
)
|
||||||
|
assert [row.id for row in listed] == [existing.id]
|
||||||
|
return existing
|
||||||
|
|
||||||
|
_with_db(tmp_path, "override-overlap.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_overlap_is_per_child(tmp_path) -> None:
|
||||||
|
"""Different children's overrides may cover the same dates: the
|
||||||
|
conflict rule is per child."""
|
||||||
|
async def _body(database, schedules, overrides, children, child):
|
||||||
|
other = await children.create("Bo", NOW)
|
||||||
|
await overrides.create(child.id, "2026-07-20", "2026-07-24", False)
|
||||||
|
twin = await overrides.create(
|
||||||
|
other.id, "2026-07-20", "2026-07-24", True
|
||||||
|
)
|
||||||
|
assert twin.child_id == other.id
|
||||||
|
listed = await overrides.list_by_child_and_range(
|
||||||
|
other.id, "2026-01-01", "2026-12-31"
|
||||||
|
)
|
||||||
|
assert [row.id for row in listed] == [twin.id]
|
||||||
|
return twin
|
||||||
|
|
||||||
|
_with_db(tmp_path, "override-per-child.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_remove_then_recreate_overlapping(tmp_path) -> None:
|
||||||
|
async def _body(database, schedules, overrides, children, child):
|
||||||
|
first = await overrides.create(
|
||||||
|
child.id, "2026-07-20", "2026-07-24", False
|
||||||
|
)
|
||||||
|
assert await overrides.delete(first.id) is True
|
||||||
|
assert await overrides.delete(first.id) is False
|
||||||
|
replacement = await overrides.create(
|
||||||
|
child.id, "2026-07-20", "2026-07-24", True, note="swap back"
|
||||||
|
)
|
||||||
|
assert replacement.is_present is True
|
||||||
|
return replacement
|
||||||
|
|
||||||
|
_with_db(tmp_path, "override-recreate.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_create_racing_conflicts_serialize(tmp_path) -> None:
|
||||||
|
"""Two racing creates for the same child and dates: exactly one
|
||||||
|
lands, the other is rejected with the overlap error. DETERMINISTIC
|
||||||
|
gate: task A is paused at its INSERT — inside its open transaction,
|
||||||
|
after its overlap probe — so task B is created while A provably
|
||||||
|
holds the connection lock and must queue; B then re-runs the probe
|
||||||
|
against A's committed row and is rejected."""
|
||||||
|
import asyncio as asyncio_module
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
async def _main():
|
||||||
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
|
from custom_components.nestquest.dao_presence import (
|
||||||
|
PresenceOverridesDao,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
from custom_components.nestquest.migrations import apply_migrations
|
||||||
|
|
||||||
|
armed = {"active": False, "gated": False}
|
||||||
|
gate_open = asyncio_module.Event()
|
||||||
|
a_started = asyncio_module.Event()
|
||||||
|
|
||||||
|
hass = MagicMock()
|
||||||
|
|
||||||
|
async def _gated_executor(fn, *args):
|
||||||
|
if armed["active"]:
|
||||||
|
# Gate the FIRST _execute after arming: task A's INSERT,
|
||||||
|
# which runs inside its open transaction (BEGIN already
|
||||||
|
# executed) after the overlap probe.
|
||||||
|
if getattr(fn, "__name__", "") == "_execute" and not armed["gated"]:
|
||||||
|
armed["gated"] = True
|
||||||
|
a_started.set()
|
||||||
|
await gate_open.wait()
|
||||||
|
return fn(*args)
|
||||||
|
|
||||||
|
hass.async_add_executor_job = _gated_executor
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
await database.open(tmp_path / "override-race.db")
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
overrides = PresenceOverridesDao(database)
|
||||||
|
child = await children.create("Ada", NOW)
|
||||||
|
|
||||||
|
armed["active"] = True
|
||||||
|
task_a = asyncio_module.ensure_future(
|
||||||
|
overrides.create(child.id, "2026-07-20", "2026-07-24", True)
|
||||||
|
)
|
||||||
|
await a_started.wait()
|
||||||
|
armed["active"] = False
|
||||||
|
task_b = asyncio_module.ensure_future(
|
||||||
|
overrides.create(child.id, "2026-07-22", "2026-07-26", False)
|
||||||
|
)
|
||||||
|
await asyncio_module.sleep(0)
|
||||||
|
gate_open.set()
|
||||||
|
record_a, error_b = await asyncio_module.gather(
|
||||||
|
task_a, task_b, return_exceptions=True
|
||||||
|
)
|
||||||
|
assert isinstance(record_a, object) and not isinstance(
|
||||||
|
record_a, BaseException
|
||||||
|
), f"A must land: {record_a!r}"
|
||||||
|
assert isinstance(error_b, ValueError), (
|
||||||
|
f"B must be rejected with the overlap error: {error_b!r}"
|
||||||
|
)
|
||||||
|
assert "overlaps existing" in str(error_b)
|
||||||
|
listed = await overrides.list_by_child_and_range(
|
||||||
|
child.id, "2026-01-01", "2026-12-31"
|
||||||
|
)
|
||||||
|
assert [row.id for row in listed] == [record_a.id]
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
asyncio.new_event_loop().run_until_complete(_main())
|
||||||
|
|||||||
@@ -9,13 +9,21 @@ import pytest
|
|||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.dao_rules import (
|
from custom_components.nestquest.dao_rules import (
|
||||||
ScheduleRuleRecord,
|
ScheduleRuleRecord,
|
||||||
|
ScheduleRuleStorage,
|
||||||
ScheduleRulesDao,
|
ScheduleRulesDao,
|
||||||
QuestDefinitionRecord,
|
QuestDefinitionRecord,
|
||||||
QuestDefinitionWindowRecord,
|
QuestDefinitionWindowRecord,
|
||||||
QuestDefinitionsDao,
|
QuestDefinitionsDao,
|
||||||
|
schedule_rule_from_storage,
|
||||||
|
schedule_rule_to_storage,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.migrations import apply_migrations
|
||||||
|
from custom_components.nestquest.recurrence import (
|
||||||
|
RuleType,
|
||||||
|
RuleValidationError,
|
||||||
|
ScheduleRule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
@@ -423,6 +431,249 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
|||||||
assert create_won is False
|
assert create_won is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ScheduleRule <-> storage mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_RULE_STORAGE_CASES = [
|
||||||
|
(
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"daily", 1, None, None, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.DAILY,
|
||||||
|
interval=3,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
end_date="2026-12-31",
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"daily", 3, None, None, None, None, "2026-09-01", "2026-12-31"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.WEEKLY,
|
||||||
|
weekday_set={2, 0, 4},
|
||||||
|
start_date="2026-09-01",
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"weekly", 1, "0,2,4", None, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.CUSTOM_DAYS,
|
||||||
|
weekday_set={1, 3, 5},
|
||||||
|
interval=2,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"custom", 2, "1,3,5", None, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.MONTHLY_DAY,
|
||||||
|
day_of_month=15,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 1, None, 15, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.MONTHLY_DAY,
|
||||||
|
day_of_month=31,
|
||||||
|
interval=3,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 3, None, 31, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.MONTHLY_WEEKDAY,
|
||||||
|
nth_weekday=2,
|
||||||
|
nth_weekday_weekday=1,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 1, "1", None, 2, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.MONTHLY_WEEKDAY,
|
||||||
|
nth_weekday=-1,
|
||||||
|
nth_weekday_weekday=4,
|
||||||
|
interval=2,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 2, "4", None, -1, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01"
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"yearly", 1, None, None, None, 6, "2026-09-01", None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.YEARLY,
|
||||||
|
month=2,
|
||||||
|
day_of_month=29,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"yearly", 1, None, 29, None, 2, "2026-09-01", None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
|
||||||
|
def test_schedule_rule_to_storage_maps_each_type(rule, storage) -> None:
|
||||||
|
assert schedule_rule_to_storage(rule) == storage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
|
||||||
|
def test_schedule_rule_from_storage_rebuilds_each_type(rule, storage) -> None:
|
||||||
|
assert schedule_rule_from_storage(storage) == rule
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
|
||||||
|
def test_schedule_rule_round_trips_losslessly_both_directions(
|
||||||
|
rule, storage
|
||||||
|
) -> None:
|
||||||
|
assert schedule_rule_from_storage(schedule_rule_to_storage(rule)) == rule
|
||||||
|
assert schedule_rule_to_storage(schedule_rule_from_storage(storage)) == storage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"storage",
|
||||||
|
[
|
||||||
|
# monthly with neither day_of_month nor nth_weekday
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 1, None, None, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
# monthly with both day_of_month and nth_weekday
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 1, None, 15, 2, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
# MONTHLY_WEEKDAY whose weekday_set is None
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 1, None, None, 2, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
# MONTHLY_WEEKDAY whose weekday_set is empty
|
||||||
|
ScheduleRuleStorage("monthly", 1, "", None, 2, None, "2026-09-01", None),
|
||||||
|
# MONTHLY_WEEKDAY whose weekday_set is multi-element
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 1, "1,3", None, 2, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
# unknown storage rule_type
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"sometimes", 1, None, None, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_schedule_rule_from_storage_rejects_ambiguous_or_invalid(
|
||||||
|
storage,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(RuleValidationError):
|
||||||
|
schedule_rule_from_storage(storage)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_rule_from_storage_rejects_malformed_weekday_csv() -> None:
|
||||||
|
with pytest.raises(RuleValidationError):
|
||||||
|
schedule_rule_from_storage(
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"weekly", 1, "0,2,x", None, None, None, "2026-09-01", None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(RuleValidationError):
|
||||||
|
schedule_rule_from_storage(
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"weekly", 1, "7", None, None, None, "2026-09-01", None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"storage",
|
||||||
|
[
|
||||||
|
# DAILY carrying shape columns it must not hold.
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"daily", 1, "0", None, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"daily", 1, None, 15, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"daily", 1, None, None, 2, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"daily", 1, None, None, None, 6, "2026-09-01", None
|
||||||
|
),
|
||||||
|
# WEEKLY carrying shape columns it must not hold.
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"weekly", 1, "0,2", 15, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"weekly", 1, "0,2", None, None, 6, "2026-09-01", None
|
||||||
|
),
|
||||||
|
# CUSTOM_DAYS carrying shape columns it must not hold.
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"custom", 1, "0,2", 15, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"custom", 1, "0,2", None, None, 6, "2026-09-01", None
|
||||||
|
),
|
||||||
|
# MONTHLY_DAY carrying weekday_set (folded-only column) or month.
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 1, "1", 15, None, None, "2026-09-01", None
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 1, None, 15, None, 6, "2026-09-01", None
|
||||||
|
),
|
||||||
|
# MONTHLY_WEEKDAY carrying a forbidden month.
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 1, "1", None, 2, 6, "2026-09-01", None
|
||||||
|
),
|
||||||
|
# YEARLY carrying weekday_set or nth_weekday.
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"yearly", 1, "0", None, None, 6, "2026-09-01", None
|
||||||
|
),
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"yearly", 1, None, None, 2, 6, "2026-09-01", None
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_schedule_rule_from_storage_rejects_forbidden_fields(storage) -> None:
|
||||||
|
with pytest.raises(RuleValidationError):
|
||||||
|
schedule_rule_from_storage(storage)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_rule_from_storage_rejects_duplicate_weekday_token() -> None:
|
||||||
|
"""A MONTHLY_WEEKDAY with a duplicate CSV token ("1,1") must raise,
|
||||||
|
never silently canonicalize to a single weekday on re-storage."""
|
||||||
|
with pytest.raises(RuleValidationError):
|
||||||
|
schedule_rule_from_storage(
|
||||||
|
ScheduleRuleStorage(
|
||||||
|
"monthly", 1, "1,1", None, 2, None, "2026-09-01", None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# quest_definitions: create / get
|
# quest_definitions: create / get
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+127
-1
@@ -8,7 +8,11 @@ import pytest
|
|||||||
from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
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 DOMAIN as DOMAIN_CONST
|
from custom_components.nestquest.const import (
|
||||||
|
CONF_DAY_ROLLOVER_TIME,
|
||||||
|
DEFAULT_HORIZON_DAYS,
|
||||||
|
DOMAIN as DOMAIN_CONST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _wire(entry, registry):
|
def _wire(entry, registry):
|
||||||
@@ -237,6 +241,128 @@ async def test_unload_closes_database_even_when_listener_removal_raises() -> Non
|
|||||||
assert database.connected is False, "database leaked despite remover error"
|
assert database.connected is False, "database leaked despite remover error"
|
||||||
assert entry.runtime_data is None
|
assert entry.runtime_data is None
|
||||||
assert DOMAIN not in hass.data
|
assert DOMAIN not in hass.data
|
||||||
|
assert hass.time_change.size == 0, "time-change listener leaked"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_cancels_time_listener_even_when_update_remover_raises() -> None:
|
||||||
|
"""A raising update-listener remover must not skip the time-change cancel.
|
||||||
|
|
||||||
|
Both removers are independent: the update-listener remover raising must
|
||||||
|
still cancel the daily time-change callback, whose action would otherwise
|
||||||
|
fire after unload against a closed database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class _RemoveBoom(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _remover():
|
||||||
|
raise _RemoveBoom()
|
||||||
|
|
||||||
|
entry = make_config_entry(entry_id="update-boom-time-cancel")
|
||||||
|
entry.add_update_listener = lambda listener: _remover
|
||||||
|
hass, _registry = make_hass()
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
assert hass.time_change.size == 1
|
||||||
|
with pytest.raises(_RemoveBoom):
|
||||||
|
await async_unload_entry(hass, entry)
|
||||||
|
assert hass.time_change.size == 0, "time-change listener not cancelled"
|
||||||
|
assert entry.runtime_data is None
|
||||||
|
assert DOMAIN not in hass.data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_registers_day_rollover_listener(hass, make_entry) -> None:
|
||||||
|
"""Setup registers a daily rollover listener and stores its cancel fn."""
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
assert hass.time_change.size == 1
|
||||||
|
registration = hass.time_change.registrations[0]
|
||||||
|
assert registration["hour"] == 0
|
||||||
|
assert registration["minute"] == 0
|
||||||
|
assert callable(entry.runtime_data.remove_time_change_listener)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_registers_day_rollover_at_configured_time(hass, make_entry) -> None:
|
||||||
|
"""The listener fires at the entry option's rollover time, not the default."""
|
||||||
|
entry = _wire(make_entry(options={CONF_DAY_ROLLOVER_TIME: "06:30"}), hass.registry)
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
registration = hass.time_change.registrations[0]
|
||||||
|
assert registration["hour"] == 6
|
||||||
|
assert registration["minute"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_cancels_day_rollover_listener(hass, make_entry) -> None:
|
||||||
|
"""Unload removes the time-change listener alongside the update listener."""
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
assert hass.time_change.size == 1
|
||||||
|
assert await async_unload_entry(hass, entry) is True
|
||||||
|
assert hass.time_change.size == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rollover_time_change_reregisters_listener_at_new_time(
|
||||||
|
hass, make_entry
|
||||||
|
) -> None:
|
||||||
|
"""A rollover-time options change re-registers the listener at the new time."""
|
||||||
|
entry = _wire(
|
||||||
|
make_entry(options={CONF_DAY_ROLLOVER_TIME: "00:00"}), hass.registry
|
||||||
|
)
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
assert (hass.time_change.registrations[0]["hour"], hass.time_change.registrations[0]["minute"]) == (0, 0)
|
||||||
|
|
||||||
|
# The options flow writes the new rollover time, then HA reloads the
|
||||||
|
# entry: unload tears down the old listener, setup re-registers it.
|
||||||
|
entry.options = {CONF_DAY_ROLLOVER_TIME: "06:30"}
|
||||||
|
await async_unload_entry(hass, entry)
|
||||||
|
assert hass.time_change.size == 0
|
||||||
|
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
assert hass.time_change.size == 1
|
||||||
|
registration = hass.time_change.registrations[0]
|
||||||
|
assert (registration["hour"], registration["minute"]) == (6, 30)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_day_rollover_listener_materializes_ha_local_horizon(
|
||||||
|
hass, make_entry, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""Firing the listener materializes today..today+horizon in HA local time."""
|
||||||
|
import datetime
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import custom_components.nestquest as nestquest
|
||||||
|
|
||||||
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
await async_setup_entry(hass, entry)
|
||||||
|
database = entry.runtime_data.database
|
||||||
|
|
||||||
|
fake = AsyncMock()
|
||||||
|
monkeypatch.setattr(nestquest, "_materialize_run", fake)
|
||||||
|
|
||||||
|
time_zone = ZoneInfo(hass.config.time_zone)
|
||||||
|
today = datetime.datetime.now(time_zone).date()
|
||||||
|
expected_start = today.isoformat()
|
||||||
|
expected_end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
|
|
||||||
|
await hass.time_change.fire()
|
||||||
|
|
||||||
|
fake.assert_awaited_once_with(database, expected_start, expected_end)
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
|
||||||
|
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
|
||||||
|
|
||||||
|
Setup drives the local-time wrapper directly, so passing ``local=`` must
|
||||||
|
raise TypeError — otherwise a regression in the production call would be
|
||||||
|
silently masked by the fake.
|
||||||
|
"""
|
||||||
|
import inspect as _inspect
|
||||||
|
|
||||||
|
from homeassistant.helpers.event import async_track_time_change
|
||||||
|
|
||||||
|
params = _inspect.signature(async_track_time_change).parameters
|
||||||
|
assert list(params) == ["hass", "action", "hour", "minute", "second"]
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
async_track_time_change(None, lambda _now: None, hour=0, minute=0, local=True)
|
||||||
|
|
||||||
|
|
||||||
async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
|
async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -149,16 +149,17 @@ def test_migration_list_shape() -> None:
|
|||||||
(D-007) to create the quest_* tables directly; migration 2 exists
|
(D-007) to create the quest_* tables directly; migration 2 exists
|
||||||
for dev databases stamped version 1 by the pre-rename runner,
|
for dev databases stamped version 1 by the pre-rename runner,
|
||||||
migration 3 brings pre-D-008 definitions to the multi-assignee
|
migration 3 brings pre-D-008 definitions to the multi-assignee
|
||||||
model, migration 4 adds the windows table, and migration 5 rebuilds
|
model, migration 4 adds the windows table, migration 5 rebuilds
|
||||||
quest_instances onto the widened (definition, child, date, window)
|
quest_instances onto the widened (definition, child, date, window)
|
||||||
key.
|
key, and migration 6 adds actor_child_id to completion_events.
|
||||||
"""
|
"""
|
||||||
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
|
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
|
||||||
assert callable(MIGRATIONS[1])
|
assert callable(MIGRATIONS[1])
|
||||||
assert callable(MIGRATIONS[2])
|
assert callable(MIGRATIONS[2])
|
||||||
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
|
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
|
||||||
assert callable(MIGRATIONS[4])
|
assert callable(MIGRATIONS[4])
|
||||||
assert len(MIGRATIONS) == 5
|
assert callable(MIGRATIONS[5])
|
||||||
|
assert len(MIGRATIONS) == 6
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -442,6 +443,21 @@ _V2_INSTANCES_DDL = [
|
|||||||
""",
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_V2_EVENTS_DDL = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS completion_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
||||||
|
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
actor_source TEXT NOT NULL,
|
||||||
|
actor_user_id TEXT,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
was_on_time INTEGER
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _seed_v2_multi_assignee_upgrade(database) -> None:
|
def _seed_v2_multi_assignee_upgrade(database) -> None:
|
||||||
"""Create a database shaped like a post-rename, pre-D-008 dev file.
|
"""Create a database shaped like a post-rename, pre-D-008 dev file.
|
||||||
@@ -458,12 +474,13 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
|
|||||||
"quest_definition_assignees ",
|
"quest_definition_assignees ",
|
||||||
"quest_definition_windows ",
|
"quest_definition_windows ",
|
||||||
"quest_instances ",
|
"quest_instances ",
|
||||||
|
"completion_events ",
|
||||||
)
|
)
|
||||||
for sql in SCHEMA_V1_STATEMENTS:
|
for sql in SCHEMA_V1_STATEMENTS:
|
||||||
if any(name in sql for name in _v2_replaced):
|
if any(name in sql for name in _v2_replaced):
|
||||||
continue
|
continue
|
||||||
_run(database.execute(sql))
|
_run(database.execute(sql))
|
||||||
for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL:
|
for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL + _V2_EVENTS_DDL:
|
||||||
_run(database.execute(sql))
|
_run(database.execute(sql))
|
||||||
_run(database.execute(VERSION_TABLE_DDL))
|
_run(database.execute(VERSION_TABLE_DDL))
|
||||||
_run(
|
_run(
|
||||||
@@ -503,6 +520,26 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
|
|||||||
"VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')"
|
"VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# One legacy panel event and one legacy admin event: the panel one
|
||||||
|
# must backfill actor_child_id to its own child; the admin one must
|
||||||
|
# stay NULL.
|
||||||
|
_run(
|
||||||
|
database.execute(
|
||||||
|
"INSERT INTO completion_events (instance_id, child_id, "
|
||||||
|
"event_type, actor_source, occurred_at, was_on_time) "
|
||||||
|
"VALUES (1, 2, 'completed', 'panel', "
|
||||||
|
"'2026-09-15T10:00:00+00:00', 1)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_run(
|
||||||
|
database.execute(
|
||||||
|
"INSERT INTO completion_events (instance_id, child_id, "
|
||||||
|
"event_type, actor_source, actor_user_id, occurred_at, "
|
||||||
|
"was_on_time) "
|
||||||
|
"VALUES (1, 2, 'uncompleted', 'user', 'parent-1', "
|
||||||
|
"'2026-09-15T11:00:00+00:00', 1)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
||||||
@@ -559,6 +596,23 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
|||||||
assert [
|
assert [
|
||||||
"definition_id", "child_id", "due_date", "window"
|
"definition_id", "child_id", "due_date", "window"
|
||||||
] in unique_columns
|
] in unique_columns
|
||||||
|
# The completion events survived the actor_child_id rebuild:
|
||||||
|
# the panel row backfilled to its own child, the admin row
|
||||||
|
# stayed NULL, and ids are preserved.
|
||||||
|
events = _run(
|
||||||
|
database.fetch_all(
|
||||||
|
"SELECT id, event_type, actor_source, actor_child_id "
|
||||||
|
"FROM completion_events ORDER BY id"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert events == [
|
||||||
|
(1, "completed", "panel", 2),
|
||||||
|
(2, "uncompleted", "user", None),
|
||||||
|
]
|
||||||
|
columns = _run(
|
||||||
|
database.fetch_all("PRAGMA table_info(completion_events)")
|
||||||
|
)
|
||||||
|
assert "actor_child_id" in {c[1] for c in columns}
|
||||||
# No dangling references anywhere.
|
# No dangling references anywhere.
|
||||||
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
|
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
|
||||||
# The rebuilt table is live for the new model: a definition
|
# The rebuilt table is live for the new model: a definition
|
||||||
|
|||||||
+264
-3
@@ -14,6 +14,7 @@ from conftest import make_config_entry, make_hass, options_flow_base as _options
|
|||||||
from custom_components.nestquest import config_flow
|
from custom_components.nestquest import config_flow
|
||||||
from custom_components.nestquest import options_flow
|
from custom_components.nestquest import options_flow
|
||||||
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_PANEL_IDLE_TIMEOUT,
|
CONF_PANEL_IDLE_TIMEOUT,
|
||||||
@@ -46,13 +47,48 @@ def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data:
|
|||||||
return make_config_entry(entry_id=entry_id, options=options, data=data)
|
return make_config_entry(entry_id=entry_id, options=options, data=data)
|
||||||
|
|
||||||
|
|
||||||
def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
|
def _make_flow(entry, *, users=None, database=None) -> options_flow.NestQuestOptionsFlow:
|
||||||
"""Create an options flow instance bound to a stubbed config entry."""
|
"""Create an options flow instance bound to a stubbed config entry.
|
||||||
|
|
||||||
|
``users`` populates a stubbed hass.auth for the admin picker;
|
||||||
|
``database`` wires the runtime record so the flow can read and
|
||||||
|
write the allowlist like real HA's options flow does.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
||||||
flow.hass = SimpleNamespace()
|
flow.hass = SimpleNamespace()
|
||||||
|
flow.hass.auth = SimpleNamespace(
|
||||||
|
async_get_users=AsyncMock(return_value=users or [])
|
||||||
|
)
|
||||||
|
if database is not None:
|
||||||
|
from custom_components.nestquest.const import DOMAIN
|
||||||
|
|
||||||
|
flow.hass.data = {
|
||||||
|
DOMAIN: {entry.entry_id: SimpleNamespace(database=database)}
|
||||||
|
}
|
||||||
return flow
|
return flow
|
||||||
|
|
||||||
|
|
||||||
|
def _user(user_id: str, name: str):
|
||||||
|
return SimpleNamespace(id=user_id, name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_allowlist_db(tmp_path, name):
|
||||||
|
"""A real migrated temp database for the flow to read and write."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from custom_components.nestquest.db import NestQuestDatabase
|
||||||
|
from custom_components.nestquest.migrations import apply_migrations
|
||||||
|
|
||||||
|
hass = MagicMock()
|
||||||
|
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||||
|
database = NestQuestDatabase(hass)
|
||||||
|
_run(database.open(tmp_path / name))
|
||||||
|
_run(apply_migrations(database))
|
||||||
|
return database
|
||||||
|
|
||||||
|
|
||||||
def _schema_values(schema) -> dict:
|
def _schema_values(schema) -> dict:
|
||||||
"""Extract the per-field default values from a real voluptuous schema."""
|
"""Extract the per-field default values from a real voluptuous schema."""
|
||||||
return {
|
return {
|
||||||
@@ -161,7 +197,12 @@ def test_options_flow_falls_back_to_entry_data() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_options_flow_schema_applies_stored_defaults() -> None:
|
def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||||
"""A real voluptuous validate({}) fills every field from the stored defaults."""
|
"""A real voluptuous validate({}) fills every field from the stored defaults.
|
||||||
|
|
||||||
|
Without a runtime database (and no auth in the bare harness), the
|
||||||
|
admin picker's default is the empty selection and its choice set is
|
||||||
|
empty — the three legacy settings are unaffected.
|
||||||
|
"""
|
||||||
entry = _make_entry(
|
entry = _make_entry(
|
||||||
options={
|
options={
|
||||||
CONF_HORIZON_DAYS: 21,
|
CONF_HORIZON_DAYS: 21,
|
||||||
@@ -174,6 +215,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
|||||||
CONF_HORIZON_DAYS: 21,
|
CONF_HORIZON_DAYS: 21,
|
||||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
|
CONF_ADMIN_USER_IDS: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -374,3 +416,222 @@ def test_options_flow_no_hardcoded_domain_or_db_filename() -> None:
|
|||||||
assert node.value != "nestquest.db", (
|
assert node.value != "nestquest.db", (
|
||||||
f"Hard-coded database filename in {py_file.name}:{node.lineno}"
|
f"Hard-coded database filename in {py_file.name}:{node.lineno}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin allowlist picker (Feature 03)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_picker_lists_existing_users(tmp_path) -> None:
|
||||||
|
"""The picker offers EXISTING HA users (id -> name), never free text."""
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
database = _open_allowlist_db(tmp_path, "picker-users.db")
|
||||||
|
try:
|
||||||
|
from custom_components.nestquest.admin_allowlist import set_admin_ids
|
||||||
|
|
||||||
|
_run(set_admin_ids(database, ["u1"]))
|
||||||
|
entry = _make_entry()
|
||||||
|
flow = _make_flow(
|
||||||
|
entry,
|
||||||
|
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||||
|
database=database,
|
||||||
|
)
|
||||||
|
result = _run(flow.async_step_init(None))
|
||||||
|
schema = result["data_schema"]
|
||||||
|
marker = next(
|
||||||
|
m for m in schema.schema
|
||||||
|
if getattr(m, "schema", None) == CONF_ADMIN_USER_IDS
|
||||||
|
)
|
||||||
|
# Default is the CURRENT database allowlist.
|
||||||
|
values = _schema_values(schema)
|
||||||
|
assert values[CONF_ADMIN_USER_IDS] == ["u1"]
|
||||||
|
# Both users are selectable; an unknown id is rejected by the
|
||||||
|
# schema itself (this is what real HA submits go through).
|
||||||
|
validated = schema(
|
||||||
|
{
|
||||||
|
CONF_HORIZON_DAYS: 7,
|
||||||
|
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||||
|
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||||
|
CONF_ADMIN_USER_IDS: ["u1", "u2"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert validated[CONF_ADMIN_USER_IDS] == ["u1", "u2"]
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
schema(
|
||||||
|
{
|
||||||
|
CONF_HORIZON_DAYS: 7,
|
||||||
|
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||||
|
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||||
|
CONF_ADMIN_USER_IDS: ["impostor"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_submit_applies_selection_to_database(tmp_path) -> None:
|
||||||
|
database = _open_allowlist_db(tmp_path, "picker-apply.db")
|
||||||
|
try:
|
||||||
|
from custom_components.nestquest.admin_allowlist import (
|
||||||
|
list_admin_ids,
|
||||||
|
set_admin_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
_run(set_admin_ids(database, ["u1"]))
|
||||||
|
entry = _make_entry()
|
||||||
|
flow = _make_flow(
|
||||||
|
entry,
|
||||||
|
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||||
|
database=database,
|
||||||
|
)
|
||||||
|
result = _run(
|
||||||
|
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2"]})
|
||||||
|
)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"]
|
||||||
|
# The allowlist (and the entry's recovery copy) carry the pick.
|
||||||
|
assert await_gather(list_admin_ids(database)) == ["u2"]
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def await_gather(coro):
|
||||||
|
return _run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_empty_selection_refused(tmp_path) -> None:
|
||||||
|
"""Saving zero admins is refused: an empty allowlist fails closed."""
|
||||||
|
database = _open_allowlist_db(tmp_path, "picker-empty.db")
|
||||||
|
try:
|
||||||
|
from custom_components.nestquest.admin_allowlist import (
|
||||||
|
list_admin_ids,
|
||||||
|
set_admin_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
_run(set_admin_ids(database, ["u1"]))
|
||||||
|
entry = _make_entry()
|
||||||
|
flow = _make_flow(
|
||||||
|
entry,
|
||||||
|
users=[_user("u1", "Joshua")],
|
||||||
|
database=database,
|
||||||
|
)
|
||||||
|
result = _run(
|
||||||
|
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: []})
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {CONF_ADMIN_USER_IDS: "no_admins"}
|
||||||
|
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_unknown_selection_refused(tmp_path) -> None:
|
||||||
|
database = _open_allowlist_db(tmp_path, "picker-unknown.db")
|
||||||
|
try:
|
||||||
|
from custom_components.nestquest.admin_allowlist import (
|
||||||
|
list_admin_ids,
|
||||||
|
set_admin_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
_run(set_admin_ids(database, ["u1"]))
|
||||||
|
entry = _make_entry()
|
||||||
|
flow = _make_flow(
|
||||||
|
entry,
|
||||||
|
users=[_user("u1", "Joshua")],
|
||||||
|
database=database,
|
||||||
|
)
|
||||||
|
result = _run(
|
||||||
|
flow.async_step_init(
|
||||||
|
{**VALID_INPUT, CONF_ADMIN_USER_IDS: ["impostor"]}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"}
|
||||||
|
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None:
|
||||||
|
"""Raw callers omitting the key (pre-picker payloads) do not
|
||||||
|
touch the allowlist; the CURRENT allowlist is carried forward on
|
||||||
|
the options copy so no removed admin can be resurrected later."""
|
||||||
|
database = _open_allowlist_db(tmp_path, "picker-legacy.db")
|
||||||
|
try:
|
||||||
|
from custom_components.nestquest.admin_allowlist import (
|
||||||
|
list_admin_ids,
|
||||||
|
set_admin_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
_run(set_admin_ids(database, ["u1"]))
|
||||||
|
entry = _make_entry()
|
||||||
|
flow = _make_flow(
|
||||||
|
entry,
|
||||||
|
users=[_user("u1", "Joshua")],
|
||||||
|
database=database,
|
||||||
|
)
|
||||||
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"][CONF_ADMIN_USER_IDS] == ["u1"]
|
||||||
|
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_duplicate_selection_refused(tmp_path) -> None:
|
||||||
|
"""Duplicate ids surface as a field error, not an unhandled
|
||||||
|
ValueError from the business layer."""
|
||||||
|
database = _open_allowlist_db(tmp_path, "picker-dupe.db")
|
||||||
|
try:
|
||||||
|
from custom_components.nestquest.admin_allowlist import (
|
||||||
|
list_admin_ids,
|
||||||
|
set_admin_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
_run(set_admin_ids(database, ["u1"]))
|
||||||
|
entry = _make_entry()
|
||||||
|
flow = _make_flow(
|
||||||
|
entry,
|
||||||
|
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||||
|
database=database,
|
||||||
|
)
|
||||||
|
result = _run(
|
||||||
|
flow.async_step_init(
|
||||||
|
{**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2", "u2"]}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"}
|
||||||
|
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_flow_omitted_picker_carries_allowlist_forward(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""An omitted picker field must not drop the allowlist copy from
|
||||||
|
the returned options: HA replaces entry.options wholesale, and a
|
||||||
|
later database loss must not resurrect removed admins from the
|
||||||
|
stale config-flow copy in entry.data."""
|
||||||
|
database = _open_allowlist_db(tmp_path, "picker-carry.db")
|
||||||
|
try:
|
||||||
|
from custom_components.nestquest.admin_allowlist import (
|
||||||
|
set_admin_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
_run(set_admin_ids(database, ["u2"]))
|
||||||
|
entry = _make_entry()
|
||||||
|
flow = _make_flow(
|
||||||
|
entry,
|
||||||
|
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||||
|
database=database,
|
||||||
|
)
|
||||||
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
# The current allowlist rides along on the legacy three-key path.
|
||||||
|
assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"]
|
||||||
|
assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS]
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|||||||
@@ -0,0 +1,814 @@
|
|||||||
|
"""Tests for presence.py: the Feature 05 custody model."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.nestquest.presence import (
|
||||||
|
MAX_CYCLE_LENGTH_WEEKS,
|
||||||
|
PresenceEngine,
|
||||||
|
PresenceOverride,
|
||||||
|
PresenceSchedule,
|
||||||
|
cycle_week_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ANCHOR = datetime.date(2026, 1, 5) # a Monday
|
||||||
|
|
||||||
|
TWO_WEEK_PATTERN = {0: {0, 2, 4}, 1: {1, 3}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PresenceSchedule construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_holds_validated_fields() -> None:
|
||||||
|
schedule = PresenceSchedule(
|
||||||
|
3, 2, "2026-01-05", {0: {0, 2, 4}, 1: {1, 3}}
|
||||||
|
)
|
||||||
|
assert schedule.child_id == 3
|
||||||
|
assert schedule.cycle_length_weeks == 2
|
||||||
|
assert schedule.anchor_date == ANCHOR
|
||||||
|
assert schedule.pattern == {0: frozenset({0, 2, 4}), 1: frozenset({1, 3})}
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_accepts_date_objects() -> None:
|
||||||
|
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}})
|
||||||
|
assert schedule.anchor_date is ANCHOR
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_rejects_cycle_length_below_one() -> None:
|
||||||
|
with pytest.raises(ValueError, match="between 1 and"):
|
||||||
|
PresenceSchedule(1, 0, ANCHOR, {})
|
||||||
|
with pytest.raises(ValueError, match="between 1 and"):
|
||||||
|
PresenceSchedule(1, -1, ANCHOR, {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_rejects_cycle_length_above_documented_cap() -> None:
|
||||||
|
"""The 4 cap matches the schema's CHECK — both documents agree."""
|
||||||
|
with pytest.raises(ValueError, match="between 1 and"):
|
||||||
|
PresenceSchedule(1, MAX_CYCLE_LENGTH_WEEKS + 1, ANCHOR, {})
|
||||||
|
assert MAX_CYCLE_LENGTH_WEEKS == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [True, "2", 1.0, None])
|
||||||
|
def test_schedule_rejects_non_int_cycle_length(bad) -> None:
|
||||||
|
with pytest.raises(ValueError, match="cycle_length_weeks must be an integer"):
|
||||||
|
PresenceSchedule(1, bad, ANCHOR, {0: {0}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_rejects_pattern_missing_week_indices() -> None:
|
||||||
|
with pytest.raises(ValueError, match="missing \\[1\\]"):
|
||||||
|
PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_rejects_pattern_with_extra_week_indices() -> None:
|
||||||
|
with pytest.raises(ValueError, match="week index must be in 0"):
|
||||||
|
PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}, 2: {2}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_rejects_pattern_with_non_int_week_keys() -> None:
|
||||||
|
with pytest.raises(ValueError, match="week index must be an integer"):
|
||||||
|
PresenceSchedule(1, 1, ANCHOR, {"0": {0}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_rejects_pattern_with_bool_week_key() -> None:
|
||||||
|
"""True would silently be week 1; bools are never valid indices."""
|
||||||
|
with pytest.raises(ValueError, match="week index must be an integer"):
|
||||||
|
PresenceSchedule(1, 2, ANCHOR, {0: {0}, True: {1}})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_weekday", [-1, 7, 100])
|
||||||
|
def test_schedule_rejects_weekday_outside_zero_to_six(bad_weekday) -> None:
|
||||||
|
with pytest.raises(ValueError, match="weekday must be in 0..6"):
|
||||||
|
PresenceSchedule(1, 1, ANCHOR, {0: {bad_weekday}})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [True, "1", 1.5, None])
|
||||||
|
def test_schedule_rejects_non_int_weekdays(bad) -> None:
|
||||||
|
with pytest.raises(ValueError, match="weekday must be an int"):
|
||||||
|
PresenceSchedule(1, 1, ANCHOR, {0: [bad]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_rejects_string_and_none_week_sets() -> None:
|
||||||
|
"""A string is iterable — "02" would silently mean {0, 2}."""
|
||||||
|
with pytest.raises(ValueError, match="iterable of weekday ints"):
|
||||||
|
PresenceSchedule(1, 1, ANCHOR, {0: "02"})
|
||||||
|
with pytest.raises(ValueError, match="iterable of weekday ints"):
|
||||||
|
PresenceSchedule(1, 1, ANCHOR, {0: None})
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_rejects_non_dict_pattern() -> None:
|
||||||
|
with pytest.raises(ValueError, match="must map week index"):
|
||||||
|
PresenceSchedule(1, 1, ANCHOR, "0,2")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [True, 1.0, "2026-01-05", None])
|
||||||
|
def test_schedule_rejects_non_int_child_id(bad) -> None:
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
PresenceSchedule(bad, 1, ANCHOR, {0: {0}})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad", ["2026-1-5", "2026/01/05", "not-a-date", 5, None, True]
|
||||||
|
)
|
||||||
|
def test_schedule_rejects_non_strict_anchor_dates(bad) -> None:
|
||||||
|
with pytest.raises(ValueError, match="anchor_date"):
|
||||||
|
PresenceSchedule(1, 1, bad, {0: {0}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_empty_week_means_absent_week() -> None:
|
||||||
|
"""An explicitly empty week is valid: absent every day of it —
|
||||||
|
distinct from having no schedule (the engine's default)."""
|
||||||
|
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()})
|
||||||
|
assert schedule.pattern[1] == frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_empty_week_encodes_as_empty_segment() -> None:
|
||||||
|
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()})
|
||||||
|
assert schedule.encode() == "0,2|"
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_equal_patterns_are_equal_and_encode_identically() -> None:
|
||||||
|
one = PresenceSchedule(1, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}})
|
||||||
|
two = PresenceSchedule(2, 2, ANCHOR, {0: {0, 2, 4}, 1: {1, 3}})
|
||||||
|
assert one.encode() == two.encode() == "0,2,4|1,3"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Serialization round-trip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"encoded",
|
||||||
|
[
|
||||||
|
"0,2,4|1,3",
|
||||||
|
"0|1,2,3,4,5,6",
|
||||||
|
"|",
|
||||||
|
"0,2|",
|
||||||
|
"|0,2",
|
||||||
|
"6",
|
||||||
|
"0,1,2,3,4,5,6|",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_schedule_decode_encode_round_trips_losslessly(encoded) -> None:
|
||||||
|
schedule = PresenceSchedule.decode(1, ANCHOR, encoded)
|
||||||
|
assert schedule.encode() == encoded
|
||||||
|
again = PresenceSchedule.decode(1, ANCHOR, schedule.encode())
|
||||||
|
assert again == schedule
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_decode_sets_cycle_length_from_segments() -> None:
|
||||||
|
one_week = PresenceSchedule.decode(1, ANCHOR, "0,2,4")
|
||||||
|
assert one_week.cycle_length_weeks == 1
|
||||||
|
four_week = PresenceSchedule.decode(1, ANCHOR, "0|1|2|3,4")
|
||||||
|
assert four_week.cycle_length_weeks == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_decode_rejects_malformed_segments() -> None:
|
||||||
|
for bad in ("0,12|1", "0,,2|1", "x|1", "0|-1|2", "8", "0,|1"):
|
||||||
|
with pytest.raises(ValueError, match="invalid weekday"):
|
||||||
|
PresenceSchedule.decode(1, ANCHOR, bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_decode_rejects_non_string() -> None:
|
||||||
|
with pytest.raises(ValueError, match="must be a string"):
|
||||||
|
PresenceSchedule.decode(1, ANCHOR, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_decode_rejects_too_many_segments(tmp_path) -> None:
|
||||||
|
with pytest.raises(ValueError, match="between 1 and"):
|
||||||
|
PresenceSchedule.decode(1, ANCHOR, "0|1|2|3|4")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PresenceOverride
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_single_date_and_range_cover() -> None:
|
||||||
|
single = PresenceOverride(1, "2026-07-20", "2026-07-20", False)
|
||||||
|
assert single.covers(datetime.date(2026, 7, 20))
|
||||||
|
assert not single.covers(datetime.date(2026, 7, 21))
|
||||||
|
span = PresenceOverride(1, "2026-07-20", "2026-07-24", True, note="trade")
|
||||||
|
assert span.covers(datetime.date(2026, 7, 22))
|
||||||
|
assert not span.covers(datetime.date(2026, 7, 19))
|
||||||
|
assert not span.covers(datetime.date(2026, 7, 25))
|
||||||
|
assert span.note == "trade"
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_end_before_start_rejected() -> None:
|
||||||
|
with pytest.raises(ValueError, match="end_date must be on or after"):
|
||||||
|
PresenceOverride(1, "2026-07-24", "2026-07-20", True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["1", 0, 1.0, None])
|
||||||
|
def test_override_rejects_non_bool_is_present(bad) -> None:
|
||||||
|
with pytest.raises(ValueError, match="is_present must be a real bool"):
|
||||||
|
PresenceOverride(1, "2026-07-20", "2026-07-20", bad)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [5, ["note"], 1.5])
|
||||||
|
def test_override_rejects_non_string_note(bad) -> None:
|
||||||
|
with pytest.raises(ValueError, match="note must be a string or None"):
|
||||||
|
PresenceOverride(1, "2026-07-20", "2026-07-20", True, note=bad)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [True, 1.0, "1"])
|
||||||
|
def test_override_rejects_non_int_child_id(bad) -> None:
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
PresenceOverride(bad, "2026-07-20", "2026-07-20", True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("start", "end"), [("2026-7-20", "2026-07-20"), ("nope", "2026-07-20")]
|
||||||
|
)
|
||||||
|
def test_override_rejects_non_strict_dates(start, end) -> None:
|
||||||
|
with pytest.raises(ValueError, match="date"):
|
||||||
|
PresenceOverride(1, start, end, True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_rejects_datetime_anchor() -> None:
|
||||||
|
"""datetime.datetime subclasses date: a bare isinstance check would
|
||||||
|
store a time component and every date comparison would TypeError."""
|
||||||
|
with pytest.raises(ValueError, match="plain calendar date"):
|
||||||
|
PresenceSchedule(1, 1, datetime.datetime(2026, 1, 5, 9, 30), {0: {0}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_rejects_datetime_bounds() -> None:
|
||||||
|
with pytest.raises(ValueError, match="plain calendar date"):
|
||||||
|
PresenceOverride(
|
||||||
|
1, datetime.datetime(2026, 7, 20), "2026-07-20", True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_pattern_is_immutable_after_construction() -> None:
|
||||||
|
"""The frozen schedule must not leak a mutable dict: injecting
|
||||||
|
unvalidated weekdays or dropping required weeks after construction
|
||||||
|
would bypass total validation."""
|
||||||
|
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: {1}})
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
schedule.pattern[0] = {9}
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
del schedule.pattern[1]
|
||||||
|
# mappingproxy exposes no mutators at all.
|
||||||
|
assert not any(
|
||||||
|
hasattr(schedule.pattern, name)
|
||||||
|
for name in ("clear", "pop", "popitem", "setdefault", "update")
|
||||||
|
)
|
||||||
|
# The validated contents are still readable and encode correctly.
|
||||||
|
assert schedule.pattern[0] == frozenset({0, 2})
|
||||||
|
assert schedule.encode() == "0,2|1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cycle arithmetic: anchor-date only, never ISO week parity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_week_index_follows_the_anchor_formula() -> None:
|
||||||
|
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}})
|
||||||
|
# The anchor day starts week 0; each whole 7 days steps the cycle.
|
||||||
|
offsets = {0: 0, 1: 0, 6: 0, 7: 1, 8: 1, 13: 1, 14: 0, 15: 0, 22: 1}
|
||||||
|
for offset, expected in offsets.items():
|
||||||
|
assert (
|
||||||
|
cycle_week_index(
|
||||||
|
schedule, ANCHOR + datetime.timedelta(days=offset)
|
||||||
|
)
|
||||||
|
== expected
|
||||||
|
), f"offset {offset}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_week_index_before_the_anchor_walks_backwards() -> None:
|
||||||
|
"""Python floor division: day -1 is the LAST week of the cycle,
|
||||||
|
wrapping backwards — never inverting."""
|
||||||
|
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}})
|
||||||
|
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=1)) == 1
|
||||||
|
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=7)) == 1
|
||||||
|
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=8)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_week_index_three_week_pattern() -> None:
|
||||||
|
schedule = PresenceSchedule(
|
||||||
|
1, 3, ANCHOR, {0: {0}, 1: {1}, 2: {2}}
|
||||||
|
)
|
||||||
|
offsets = [0, 7, 14, 21, 28]
|
||||||
|
assert [cycle_week_index(schedule, ANCHOR + datetime.timedelta(days=o)) for o in offsets] == [
|
||||||
|
0, 1, 2, 0, 1
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_week_index_stable_across_three_year_boundaries() -> None:
|
||||||
|
"""The regression this exists for: walking day by day through a
|
||||||
|
December 31st -> January 1st boundary, the cycle index steps by 0
|
||||||
|
or +1 (mod cycle length) and NEVER resets or inverts — including
|
||||||
|
2020, a 53-ISO-week year, where ISO week parity flips and would
|
||||||
|
invert a two-week custody schedule on January 1st."""
|
||||||
|
schedule = PresenceSchedule(
|
||||||
|
1, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}}
|
||||||
|
)
|
||||||
|
# Independently derived expectations (anchor 2019-01-06, index =
|
||||||
|
# floor(days-since-anchor / 7) % 2, hand-verified) — hardcoded so
|
||||||
|
# even an implementation that resets or inverts at January 1st
|
||||||
|
# cannot pass by coincidence:
|
||||||
|
expected = {
|
||||||
|
# 2019 -> 2020 boundary
|
||||||
|
datetime.date(2019, 12, 26): 0,
|
||||||
|
datetime.date(2019, 12, 27): 0,
|
||||||
|
datetime.date(2019, 12, 28): 0,
|
||||||
|
datetime.date(2019, 12, 29): 1,
|
||||||
|
datetime.date(2019, 12, 30): 1,
|
||||||
|
datetime.date(2019, 12, 31): 1,
|
||||||
|
datetime.date(2020, 1, 1): 1,
|
||||||
|
datetime.date(2020, 1, 2): 1,
|
||||||
|
datetime.date(2020, 1, 3): 1,
|
||||||
|
datetime.date(2020, 1, 4): 1,
|
||||||
|
datetime.date(2020, 1, 5): 0,
|
||||||
|
datetime.date(2020, 1, 6): 0,
|
||||||
|
datetime.date(2020, 1, 7): 0,
|
||||||
|
# 2020 (53 ISO weeks) -> 2021 boundary
|
||||||
|
datetime.date(2020, 12, 27): 1,
|
||||||
|
datetime.date(2020, 12, 28): 1,
|
||||||
|
datetime.date(2020, 12, 29): 1,
|
||||||
|
datetime.date(2020, 12, 30): 1,
|
||||||
|
datetime.date(2020, 12, 31): 1,
|
||||||
|
datetime.date(2021, 1, 1): 1,
|
||||||
|
datetime.date(2021, 1, 2): 1,
|
||||||
|
datetime.date(2021, 1, 3): 0,
|
||||||
|
datetime.date(2021, 1, 4): 0,
|
||||||
|
datetime.date(2021, 1, 5): 0,
|
||||||
|
datetime.date(2021, 1, 6): 0,
|
||||||
|
datetime.date(2021, 1, 7): 0,
|
||||||
|
# 2021 -> 2022 boundary
|
||||||
|
datetime.date(2021, 12, 27): 1,
|
||||||
|
datetime.date(2021, 12, 28): 1,
|
||||||
|
datetime.date(2021, 12, 29): 1,
|
||||||
|
datetime.date(2021, 12, 30): 1,
|
||||||
|
datetime.date(2021, 12, 31): 1,
|
||||||
|
datetime.date(2022, 1, 1): 1,
|
||||||
|
datetime.date(2022, 1, 2): 0,
|
||||||
|
datetime.date(2022, 1, 3): 0,
|
||||||
|
datetime.date(2022, 1, 4): 0,
|
||||||
|
datetime.date(2022, 1, 5): 0,
|
||||||
|
datetime.date(2022, 1, 6): 0,
|
||||||
|
datetime.date(2022, 1, 7): 0,
|
||||||
|
}
|
||||||
|
for day, wanted in expected.items():
|
||||||
|
got = cycle_week_index(schedule, day)
|
||||||
|
assert got == wanted, f"{day}: expected week {wanted}, got {got}"
|
||||||
|
# And 2020 really is the 53-ISO-week year this guards against.
|
||||||
|
assert datetime.date(2020, 12, 31).isocalendar()[1] == 53
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_uses_no_iso_week_functions() -> None:
|
||||||
|
"""D-004 guardrail: cycle math never reads ISO week numbers."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
source = Path(
|
||||||
|
__import__("custom_components.nestquest.presence", fromlist=["__file__"]).__file__
|
||||||
|
).read_text()
|
||||||
|
assert "isocalendar" not in source
|
||||||
|
assert "isoweek" not in source
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PresenceEngine: is_present with the always-present default
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _three_child_engine() -> PresenceEngine:
|
||||||
|
"""Two children on opposite weeks of one two-week rotation, one
|
||||||
|
with no schedule at all (the always-present sibling)."""
|
||||||
|
anchor = datetime.date(2026, 1, 5) # Monday, week 0
|
||||||
|
declan = PresenceSchedule(
|
||||||
|
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
|
||||||
|
)
|
||||||
|
jordyn = PresenceSchedule(
|
||||||
|
2, 2, anchor, {0: set(), 1: {0, 1, 2, 3, 4, 5, 6}}
|
||||||
|
)
|
||||||
|
return PresenceEngine({1: declan, 2: jordyn})
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_child_without_schedule_is_always_present() -> None:
|
||||||
|
"""Every day of the four-week span, all three children get the
|
||||||
|
expected verdict: Chloe (no schedule) always present; the two
|
||||||
|
rotation children complementary per the anchor pattern."""
|
||||||
|
engine = _three_child_engine()
|
||||||
|
anchor = datetime.date(2026, 1, 5)
|
||||||
|
for offset in range(28):
|
||||||
|
day = anchor + datetime.timedelta(days=offset)
|
||||||
|
week = ((day - anchor).days // 7) % 2
|
||||||
|
assert engine.is_present(3, day) is True, f"offset {offset}: Chloe"
|
||||||
|
assert engine.is_present(1, day) is (week == 0), (
|
||||||
|
f"offset {offset}: Declan should follow week {week}"
|
||||||
|
)
|
||||||
|
assert engine.is_present(2, day) is (week == 1), (
|
||||||
|
f"offset {offset}: Jordyn should follow week {week}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_alternating_children_are_complementary_for_four_weeks() -> None:
|
||||||
|
"""Every day of a four-week span: the two rotation children swap
|
||||||
|
presence exactly per the anchor pattern — when one is home the
|
||||||
|
other is away."""
|
||||||
|
engine = _three_child_engine()
|
||||||
|
anchor = datetime.date(2026, 1, 5)
|
||||||
|
for offset in range(28):
|
||||||
|
day = anchor + datetime.timedelta(days=offset)
|
||||||
|
d = engine.is_present(1, day)
|
||||||
|
j = engine.is_present(2, day)
|
||||||
|
assert d == (not j), f"offset {offset}: {d}/{j} not complementary"
|
||||||
|
expected_week = ((day - anchor).days // 7) % 2
|
||||||
|
expected_present = day.weekday() in {0, 1, 2, 3, 4, 5, 6}
|
||||||
|
assert engine.is_present(1, day) == (expected_week == 0), (
|
||||||
|
f"offset {offset}: Declan week {expected_week}"
|
||||||
|
)
|
||||||
|
# Spot-check the swap days.
|
||||||
|
assert engine.is_present(1, anchor) is True
|
||||||
|
assert engine.is_present(2, anchor) is False
|
||||||
|
assert engine.is_present(1, anchor + datetime.timedelta(days=7)) is False
|
||||||
|
assert engine.is_present(2, anchor + datetime.timedelta(days=7)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_weekend_absence_respects_the_pattern() -> None:
|
||||||
|
"""A weekday-set pattern excludes days outside the set even in the
|
||||||
|
child's present week."""
|
||||||
|
engine = PresenceEngine(
|
||||||
|
{
|
||||||
|
1: PresenceSchedule(
|
||||||
|
1, 2, datetime.date(2026, 1, 5), {0: {1, 3}, 1: {2, 4}}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 2026-01-06 (Tue) is week 0, weekday 1 -> present.
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 6)) is True
|
||||||
|
# 2026-01-07 (Wed) is week 0, weekday 2 -> absent.
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 7)) is False
|
||||||
|
# 2026-01-14 (Wed) is week 1, weekday 2 -> present.
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 14)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_rejects_bad_construction_and_inputs() -> None:
|
||||||
|
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}})
|
||||||
|
with pytest.raises(ValueError, match="must map child_id"):
|
||||||
|
PresenceEngine([schedule])
|
||||||
|
with pytest.raises(ValueError, match="must be a PresenceSchedule"):
|
||||||
|
PresenceEngine({1: "not-a-schedule"})
|
||||||
|
with pytest.raises(ValueError, match="holds the schedule"):
|
||||||
|
PresenceEngine({2: schedule})
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
PresenceEngine({True: schedule})
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
PresenceEngine({1.0: PresenceSchedule(1, 1, ANCHOR, {0: {0}})})
|
||||||
|
engine = PresenceEngine({1: schedule})
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
engine.is_present(True, ANCHOR)
|
||||||
|
with pytest.raises(ValueError, match="plain calendar date"):
|
||||||
|
engine.is_present(1, datetime.datetime(2026, 1, 5))
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_is_immutable() -> None:
|
||||||
|
engine = _three_child_engine()
|
||||||
|
with pytest.raises(AttributeError, match="immutable snapshot"):
|
||||||
|
engine._schedules = {}
|
||||||
|
with pytest.raises(AttributeError, match="immutable snapshot"):
|
||||||
|
engine.something_new = 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Overrides take precedence over the pattern
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _engine_with_overrides(*overrides_for_declan) -> PresenceEngine:
|
||||||
|
anchor = datetime.date(2026, 1, 5) # Monday
|
||||||
|
return PresenceEngine(
|
||||||
|
{
|
||||||
|
1: PresenceSchedule(
|
||||||
|
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
overrides={1: list(overrides_for_declan)} if overrides_for_declan else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_override_on_normally_present_day_wins() -> None:
|
||||||
|
# 2026-01-06 is Tuesday of Declan's week 0: normally present.
|
||||||
|
engine = _engine_with_overrides(
|
||||||
|
PresenceOverride(1, "2026-01-06", "2026-01-06", False, note="away")
|
||||||
|
)
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
|
||||||
|
# The pattern resumes outside the override.
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 7)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_present_override_on_normally_absent_day_wins() -> None:
|
||||||
|
# 2026-01-13 is in Declan's week 1 (away all week); a present
|
||||||
|
# override on the Tuesday of week 1 brings him home for that day.
|
||||||
|
engine = _engine_with_overrides(
|
||||||
|
PresenceOverride(1, "2026-01-13", "2026-01-13", True, note="trade")
|
||||||
|
)
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 13)) is True
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 14)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_applies_without_a_schedule() -> None:
|
||||||
|
"""An override on a schedule-free child still applies: holidays and
|
||||||
|
swaps must work for the always-present sibling too."""
|
||||||
|
engine = PresenceEngine(
|
||||||
|
{},
|
||||||
|
overrides={
|
||||||
|
3: [
|
||||||
|
PresenceOverride(
|
||||||
|
3, "2026-07-20", "2026-07-24", False, note="holiday"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert engine.is_present(3, datetime.date(2026, 7, 22)) is False
|
||||||
|
assert engine.is_present(3, datetime.date(2026, 7, 19)) is True
|
||||||
|
assert engine.is_present(3, datetime.date(2026, 7, 25)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_range_beats_pattern_for_its_span() -> None:
|
||||||
|
engine = _engine_with_overrides(
|
||||||
|
PresenceOverride(1, "2026-01-13", "2026-01-17", True, note="traded week")
|
||||||
|
)
|
||||||
|
for offset in range(12, 17):
|
||||||
|
day = datetime.date(2026, 1, 1) + datetime.timedelta(days=offset)
|
||||||
|
assert engine.is_present(1, day) is True, f"{day}"
|
||||||
|
# Days outside the span stay on the pattern (week 1: absent).
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 12)) is False
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 18)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_override_snapshot_validation() -> None:
|
||||||
|
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}})
|
||||||
|
entry = PresenceOverride(1, "2026-07-20", "2026-07-20", True)
|
||||||
|
with pytest.raises(ValueError, match="must map child_id"):
|
||||||
|
PresenceEngine({1: schedule}, overrides=[entry])
|
||||||
|
with pytest.raises(ValueError, match="must be a list"):
|
||||||
|
PresenceEngine({1: schedule}, overrides={1: entry})
|
||||||
|
with pytest.raises(ValueError, match="must be"):
|
||||||
|
PresenceEngine({1: schedule}, overrides={1: ["nope"]})
|
||||||
|
with pytest.raises(ValueError, match="holds an override"):
|
||||||
|
PresenceEngine({1: schedule}, overrides={2: [entry]})
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
PresenceEngine({1: schedule}, overrides={True: [entry]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_overlapping_snapshot_uses_latest_start() -> None:
|
||||||
|
"""The DAO layer refuses overlapping overrides per child; if a
|
||||||
|
snapshot carries them anyway (hand-built), the latest start date
|
||||||
|
wins deterministically."""
|
||||||
|
engine = _engine_with_overrides(
|
||||||
|
PresenceOverride(1, "2026-01-06", "2026-01-10", False),
|
||||||
|
PresenceOverride(1, "2026-01-08", "2026-01-12", True),
|
||||||
|
)
|
||||||
|
# Jan 6-7: only the earlier override covers -> absent.
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
|
||||||
|
# Jan 8-10: both cover; the LATER start (present) wins.
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 9)) is True
|
||||||
|
# Jan 11-12: only the later override covers -> present.
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 11)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_override_lists_are_frozen() -> None:
|
||||||
|
"""The overrides snapshot must be truly read-only: the engine is
|
||||||
|
built directly on the CALLER's list, so mutating that list after
|
||||||
|
construction would change answers under a shallow copy — under the
|
||||||
|
frozen snapshot it must not."""
|
||||||
|
entry = PresenceOverride(1, "2026-01-06", "2026-01-06", False)
|
||||||
|
entries = [entry]
|
||||||
|
schedule = PresenceSchedule(
|
||||||
|
1, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
|
||||||
|
)
|
||||||
|
engine = PresenceEngine(
|
||||||
|
{1: schedule}, overrides={1: entries} # caller-owned list
|
||||||
|
)
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
|
||||||
|
entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False))
|
||||||
|
# The late append must NOT change the engine's answers...
|
||||||
|
assert engine.is_present(1, datetime.date(2026, 1, 7)) is True
|
||||||
|
# Tuples have no append at all (AttributeError), and the mapping
|
||||||
|
# itself refuses reassignment.
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
engine._overrides[1].append(entry)
|
||||||
|
with pytest.raises(AttributeError, match="immutable snapshot"):
|
||||||
|
engine._overrides = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# next_present_dates: the parent's pattern preview
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_engine() -> PresenceEngine:
|
||||||
|
"""Declan: home all of week 0, away all of week 1 (anchor
|
||||||
|
2026-01-05, a Monday); an away override Tue Jan 13 (his away week)
|
||||||
|
would matter only for the override test."""
|
||||||
|
return PresenceEngine(
|
||||||
|
{
|
||||||
|
1: PresenceSchedule(
|
||||||
|
1, 2, datetime.date(2026, 1, 5),
|
||||||
|
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_returns_the_next_present_days_of_a_known_pattern() -> None:
|
||||||
|
engine = _preview_engine()
|
||||||
|
# Starting Monday Jan 5 (present week 0): five straight days.
|
||||||
|
preview = engine.next_present_dates(1, "2026-01-05", 5)
|
||||||
|
assert preview == [
|
||||||
|
datetime.date(2026, 1, 5),
|
||||||
|
datetime.date(2026, 1, 6),
|
||||||
|
datetime.date(2026, 1, 7),
|
||||||
|
datetime.date(2026, 1, 8),
|
||||||
|
datetime.date(2026, 1, 9),
|
||||||
|
]
|
||||||
|
# Starting mid-week: the rest of week 0 first, then the absent
|
||||||
|
# week 1 is skipped and the next present week picks up.
|
||||||
|
preview = engine.next_present_dates(1, "2026-01-07", 3)
|
||||||
|
assert preview == [
|
||||||
|
datetime.date(2026, 1, 7),
|
||||||
|
datetime.date(2026, 1, 8),
|
||||||
|
datetime.date(2026, 1, 9),
|
||||||
|
]
|
||||||
|
preview = engine.next_present_dates(1, "2026-01-07", 6)
|
||||||
|
# Week 0 runs Mon Jan 5 .. Sun Jan 11 (all present); the away week
|
||||||
|
# (Jan 12-18) is skipped; week 2 resumes Jan 19.
|
||||||
|
assert preview == [
|
||||||
|
datetime.date(2026, 1, 7),
|
||||||
|
datetime.date(2026, 1, 8),
|
||||||
|
datetime.date(2026, 1, 9),
|
||||||
|
datetime.date(2026, 1, 10),
|
||||||
|
datetime.date(2026, 1, 11),
|
||||||
|
datetime.date(2026, 1, 19),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_start_date_is_inclusive() -> None:
|
||||||
|
engine = _preview_engine()
|
||||||
|
# Jan 12 (Monday of the away week) is absent: the first present day
|
||||||
|
# is Jan 19 — the start date itself when present.
|
||||||
|
preview = engine.next_present_dates(1, "2026-01-19", 1)
|
||||||
|
assert preview == [datetime.date(2026, 1, 19)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_honours_overrides() -> None:
|
||||||
|
schedule = PresenceSchedule(
|
||||||
|
1, 2, datetime.date(2026, 1, 5),
|
||||||
|
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
|
||||||
|
)
|
||||||
|
engine = PresenceEngine(
|
||||||
|
{1: schedule},
|
||||||
|
overrides={
|
||||||
|
1: [
|
||||||
|
PresenceOverride(
|
||||||
|
1, "2026-01-05", "2026-01-06", False, note="holiday"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Jan 5-6 blocked by the override: the preview starts Jan 7.
|
||||||
|
preview = engine.next_present_dates(1, "2026-01-04", 3)
|
||||||
|
assert preview == [
|
||||||
|
datetime.date(2026, 1, 7),
|
||||||
|
datetime.date(2026, 1, 8),
|
||||||
|
datetime.date(2026, 1, 9),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_schedule_free_child_returns_consecutive_days() -> None:
|
||||||
|
engine = PresenceEngine({})
|
||||||
|
preview = engine.next_present_dates(3, "2026-03-01", 3)
|
||||||
|
assert preview == [
|
||||||
|
datetime.date(2026, 3, 1),
|
||||||
|
datetime.date(2026, 3, 2),
|
||||||
|
datetime.date(2026, 3, 3),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_rejects_bad_inputs() -> None:
|
||||||
|
engine = _preview_engine()
|
||||||
|
with pytest.raises(ValueError, match="count must be an integer"):
|
||||||
|
engine.next_present_dates(1, "2026-01-05", "3")
|
||||||
|
with pytest.raises(ValueError, match="count must be at least 1"):
|
||||||
|
engine.next_present_dates(1, "2026-01-05", 0)
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
engine.next_present_dates(True, "2026-01-05", 1)
|
||||||
|
with pytest.raises(ValueError, match="plain calendar date"):
|
||||||
|
engine.next_present_dates(1, datetime.datetime(2026, 1, 5), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_always_absent_child_raises_not_loops() -> None:
|
||||||
|
"""A child absent every scanned day must surface as an error, not
|
||||||
|
hang the admin preview."""
|
||||||
|
engine = PresenceEngine(
|
||||||
|
{
|
||||||
|
1: PresenceSchedule(
|
||||||
|
1, 1, datetime.date(2026, 1, 5), {0: set()}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="not present on any"):
|
||||||
|
engine.next_present_dates(1, "2026-01-05", 2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The week-parity regression guard (Feature 05, D-004)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("year", "length", "reason"),
|
||||||
|
[
|
||||||
|
(2020, 366, "leap year, 53 ISO weeks"),
|
||||||
|
(2015, 365, "365 days, 53 ISO weeks"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_custody_rotation_walks_full_years_without_inversion(
|
||||||
|
year, length, reason
|
||||||
|
) -> None:
|
||||||
|
"""REGRESSION GUARD for the week-parity bug (D-004).
|
||||||
|
|
||||||
|
Walks a two-week alternating rotation day by day across a FULL
|
||||||
|
{length}-day year ({year} — {reason}) plus the following January,
|
||||||
|
asserting the present/absent sequence never breaks or inverts at
|
||||||
|
any point, including January 1st. ISO week parity silently flips
|
||||||
|
at the ISO-week rollover (the Monday on or after January 4th —
|
||||||
|
January 4th itself in both of these years, which end ISO week 53)
|
||||||
|
and would invert every custody schedule there; anchor-date
|
||||||
|
arithmetic must not.
|
||||||
|
""".format(length=length, year=year, reason=reason)
|
||||||
|
if year == 2020:
|
||||||
|
assert datetime.date(2020, 12, 31).isocalendar()[1] == 53
|
||||||
|
assert (datetime.date(2021, 1, 1) - datetime.date(2020, 1, 1)).days == 366
|
||||||
|
if year == 2015:
|
||||||
|
assert datetime.date(2015, 12, 31).isocalendar()[1] == 53
|
||||||
|
assert (datetime.date(2016, 1, 1) - datetime.date(2015, 1, 1)).days == 365
|
||||||
|
|
||||||
|
# The first Monday of January (2020: Jan 6; 2015: Jan 5) — an
|
||||||
|
# on-week anchor, whatever the year.
|
||||||
|
anchor = datetime.date(year, 1, 1) + datetime.timedelta(
|
||||||
|
days=(7 - datetime.date(year, 1, 1).weekday()) % 7
|
||||||
|
)
|
||||||
|
assert anchor.weekday() == 0
|
||||||
|
rotation = PresenceSchedule(
|
||||||
|
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
|
||||||
|
)
|
||||||
|
engine = PresenceEngine({1: rotation})
|
||||||
|
|
||||||
|
# Walk EVERY day of the year. The expectation is derived from the
|
||||||
|
# anchor's 7-day BLOCKS, not from the engine: each block of seven
|
||||||
|
# days starting at the anchor is uniformly present (even block
|
||||||
|
# index) or uniformly absent (odd block index), and consecutive
|
||||||
|
# blocks alternate. A parity-based implementation resets on
|
||||||
|
# January 1st and breaks either the uniformity of a block or the
|
||||||
|
# alternation between them — most visibly in the block spanning
|
||||||
|
# December 31st -> January 1st of a 53-ISO-week year.
|
||||||
|
# Walk the FULL year AND the first two weeks of the following
|
||||||
|
# January: the block spanning December 31st -> January 1st must
|
||||||
|
# stay uniform ACROSS the boundary, so an implementation that
|
||||||
|
# resets when the target year differs from the anchor's fails
|
||||||
|
# here (the post-boundary days of its final block disagree).
|
||||||
|
first_day = datetime.date(year, 1, 1)
|
||||||
|
last_day = datetime.date(year + 1, 1, 14)
|
||||||
|
days_seen = 0
|
||||||
|
day = first_day
|
||||||
|
while day <= last_day:
|
||||||
|
days_since_anchor = (day - anchor).days
|
||||||
|
block = days_since_anchor // 7 # which 7-day block of the cycle
|
||||||
|
block_start = anchor + datetime.timedelta(days=block * 7)
|
||||||
|
# Uniformity within the block: every day of this 7-day block
|
||||||
|
# must answer exactly like the block's first day.
|
||||||
|
expected = ((block % 2) == 0)
|
||||||
|
assert engine.is_present(1, day) is expected, (
|
||||||
|
f"{day} (block {block}, day offset "
|
||||||
|
f"{(day - block_start).days}): present="
|
||||||
|
f"{engine.is_present(1, day)}, expected {expected}"
|
||||||
|
)
|
||||||
|
# The block's OTHER days must agree — including the days on
|
||||||
|
# the far side of January 1st.
|
||||||
|
for offset in range(7):
|
||||||
|
block_day = block_start + datetime.timedelta(days=offset)
|
||||||
|
if first_day <= block_day <= last_day:
|
||||||
|
assert engine.is_present(1, block_day) is expected, (
|
||||||
|
f"{block_day} disagrees with its block {block}"
|
||||||
|
)
|
||||||
|
days_seen += 1
|
||||||
|
day += datetime.timedelta(days=1)
|
||||||
|
# The walked year itself contributed exactly its full length.
|
||||||
|
year_days = sum(
|
||||||
|
1
|
||||||
|
for offset in range((last_day - first_day).days + 1)
|
||||||
|
if (first_day + datetime.timedelta(days=offset)).year == year
|
||||||
|
)
|
||||||
|
assert year_days == length, (
|
||||||
|
f"walked {year_days} days of {year}, expected {length}"
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
+60
-3
@@ -1851,6 +1851,7 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None:
|
|||||||
("event_type", "TEXT", 1, None, 0),
|
("event_type", "TEXT", 1, None, 0),
|
||||||
("actor_source", "TEXT", 1, None, 0),
|
("actor_source", "TEXT", 1, None, 0),
|
||||||
("actor_user_id", "TEXT", 0, None, 0),
|
("actor_user_id", "TEXT", 0, None, 0),
|
||||||
|
("actor_child_id", "INTEGER", 0, None, 0),
|
||||||
("occurred_at", "TEXT", 1, None, 0),
|
("occurred_at", "TEXT", 1, None, 0),
|
||||||
("was_on_time", "INTEGER", 0, None, 0),
|
("was_on_time", "INTEGER", 0, None, 0),
|
||||||
]
|
]
|
||||||
@@ -1881,6 +1882,7 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None:
|
|||||||
assert declared == {
|
assert declared == {
|
||||||
("quest_instances", "instance_id", "id"),
|
("quest_instances", "instance_id", "id"),
|
||||||
("children", "child_id", "id"),
|
("children", "child_id", "id"),
|
||||||
|
("children", "actor_child_id", "id"),
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
_run(database.close())
|
_run(database.close())
|
||||||
@@ -2182,16 +2184,71 @@ def test_completion_events_valid_panel_completion_round_trips(tmp_path) -> None:
|
|||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
actor_source="panel",
|
actor_source="panel",
|
||||||
actor_user_id=None,
|
actor_user_id=None,
|
||||||
|
actor_child_id=1,
|
||||||
was_on_time=0,
|
was_on_time=0,
|
||||||
)
|
)
|
||||||
row = _run(
|
row = _run(
|
||||||
database.fetch_one(
|
database.fetch_one(
|
||||||
"SELECT actor_source, actor_user_id, was_on_time "
|
"SELECT actor_source, actor_user_id, actor_child_id, "
|
||||||
"FROM completion_events WHERE id = ?",
|
"was_on_time FROM completion_events WHERE id = ?",
|
||||||
(event_id,),
|
(event_id,),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert row == ("panel", None, 0)
|
assert row == ("panel", None, 1, 0)
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("actor_source", "actor_user_id", "actor_child_id"),
|
||||||
|
[
|
||||||
|
("user", "user-1", 1), # admin event must not carry a profile
|
||||||
|
("panel", None, None), # panel event requires the profile
|
||||||
|
("panel", "user-1", 1), # panel must not carry a user id either
|
||||||
|
("panel", "user-1", None),
|
||||||
|
("user", None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_completion_events_actor_child_coherence_enforced(
|
||||||
|
tmp_path, actor_source, actor_user_id, actor_child_id
|
||||||
|
) -> None:
|
||||||
|
"""D-008 actor-pair shape: 'user' events carry actor_user_id and no
|
||||||
|
actor_child_id; 'panel' events carry actor_child_id and no
|
||||||
|
actor_user_id. The CHECKs make both halves non-negotiable."""
|
||||||
|
database = _open_db(tmp_path / "completion-events-coherence.db")
|
||||||
|
try:
|
||||||
|
_apply(database)
|
||||||
|
_setup_definition(database)
|
||||||
|
instance_id = _insert_instance(database, definition_id=1)
|
||||||
|
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
|
||||||
|
_insert_event(
|
||||||
|
database,
|
||||||
|
instance_id=instance_id,
|
||||||
|
event_type="completed",
|
||||||
|
actor_source=actor_source,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
actor_child_id=actor_child_id,
|
||||||
|
was_on_time=1,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_events_actor_child_unknown_child_fails(tmp_path) -> None:
|
||||||
|
database = _open_db(tmp_path / "completion-events-bad-actor-child.db")
|
||||||
|
try:
|
||||||
|
_apply(database)
|
||||||
|
_setup_definition(database)
|
||||||
|
instance_id = _insert_instance(database, definition_id=1)
|
||||||
|
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
|
||||||
|
_insert_event(
|
||||||
|
database,
|
||||||
|
instance_id=instance_id,
|
||||||
|
actor_source="panel",
|
||||||
|
actor_user_id=None,
|
||||||
|
actor_child_id=999,
|
||||||
|
was_on_time=1,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
_run(database.close())
|
_run(database.close())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user