Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933434320a | ||
|
|
dfe8587c7a | ||
|
|
fa4415c0d5 | ||
|
|
0255017cdc | ||
|
|
f31f6dc80e | ||
|
|
a3773fec8c | ||
|
|
e85180d492 | ||
|
|
0b7f71831b | ||
|
|
a30b852789 | ||
|
|
ca1165de3f | ||
|
|
d4a55b6b40 | ||
|
|
a07ce1b2f2 | ||
|
|
550a8b3e1e | ||
|
|
4da762173c | ||
|
|
ca477125d6 | ||
|
|
7fec3c76ef |
@@ -1,32 +0,0 @@
|
||||
---
|
||||
description: Independently verify NestQuest merge gates and merge approved heads
|
||||
mode: primary
|
||||
model: openrouter/deepseek/deepseek-v4.1-flash
|
||||
permission:
|
||||
edit: deny
|
||||
write: deny
|
||||
task: deny
|
||||
bash:
|
||||
"*": deny
|
||||
"/home/overseer/.local/share/nestquest-controller/scripts/nq-approve-merge *": allow
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
"/home/overseer/.local/share/nestquest-controller/*": allow
|
||||
---
|
||||
|
||||
You are the NestQuest feature-to-dev approver, separate from the controller and
|
||||
developer sessions. Verify the current PR head equals the independently
|
||||
reviewed and approved commit,
|
||||
required checks pass, blocking findings are resolved, branch protection is
|
||||
satisfied, and Maestro has accurate links. Use only the approver Gitea identity
|
||||
for the merge. Do not change code or approve a stale head. Use only the
|
||||
installed nq-approve-merge launcher for Gitea inspection and merging. Merge
|
||||
only into dev; never merge into main. If any gate is
|
||||
missing, refuse the merge and give the exact missing evidence. Report the PR,
|
||||
reviewed head, resulting merge commit, and target branch.
|
||||
|
||||
Broker syntax: `nq-approve-merge inspect PR_NUMBER` or
|
||||
`nq-approve-merge merge PR_NUMBER REVIEWED_HEAD REVIEW_FILE`. Run one broker
|
||||
command per Bash call. The broker validates the current PR and review again
|
||||
before merging; do not substitute an ad hoc Gitea command.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
description: Implement one assigned NestQuest task in an isolated worktree
|
||||
mode: primary
|
||||
model: openrouter/z-ai/glm-5.3-flash
|
||||
permission:
|
||||
task: deny
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
---
|
||||
|
||||
You are the NestQuest development agent. Implement only the assigned Maestro
|
||||
task in the supplied worktree. Follow the task's acceptance criteria and the
|
||||
repository's instructions. Run relevant tests, commit on the task branch, and
|
||||
report the commit and test results. Keep changes inside the assigned worktree.
|
||||
Do not approve your own pull request. You may merge your task PR into the
|
||||
feature branch only after the independent reviewer approved its exact current
|
||||
head and the controller verified every task merge gate. Never merge a feature
|
||||
into dev or a release into main. Do not start another task, change role, or
|
||||
use another model. Do not read or print credential files except when the
|
||||
approved task merge requires the existing Gitea development credential.
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
description: Reconcile NestQuest work and dispatch the next verified action
|
||||
mode: primary
|
||||
model: openrouter/deepseek/deepseek-v4.1-flash
|
||||
permission:
|
||||
doom_loop: deny
|
||||
task: deny
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
"/home/overseer/.local/share/opencode/tool-output/*": allow
|
||||
---
|
||||
|
||||
You are the NestQuest controller. Work in the NestQuest repository. Maestro is
|
||||
the source of truth for feature and task state; CTXD is the source of truth for
|
||||
project instructions. At the start of every invocation, read the current
|
||||
NestQuest context in CTXD, including OPENCODE-ORCHESTRATOR-PROMPT.MD, then
|
||||
reconcile it with Maestro, Git, pull requests, and checks before dispatching.
|
||||
Follow the review, identity, and branch rules in that prompt.
|
||||
If the CTXD project or prompt is unavailable, do not dispatch or merge work;
|
||||
report the missing source and end with ORCHESTRATOR_STATE: BLOCKED.
|
||||
|
||||
One invocation is a controller tick, not an entire feature lifetime. Prefer a
|
||||
state-changing action over another identical status query. Never repeat an
|
||||
unchanged tool call three times. If work is asynchronous, wait or inspect a
|
||||
specific changed result. Do not claim that you are continuing while ending
|
||||
the turn without naming the next action.
|
||||
|
||||
The NESTQUEST_CONTROL_HOME environment variable names the installed launchers
|
||||
(normally /home/overseer/.local/share/nestquest-controller).
|
||||
For implementation, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role
|
||||
developer, a task worktree, title, and task prompt file. For independent
|
||||
review, run "$NESTQUEST_CONTROL_HOME/scripts/nq-review" with the worktree,
|
||||
feature base ref, exact head ref, review prompt file, PR number, and an output
|
||||
file under /tmp/opencode. It publishes an exact-head Gitea approval only after
|
||||
Claude approves. The developer may merge
|
||||
a task PR into its feature branch only after independent approval of the exact
|
||||
head and verification of every task gate. For feature-to-dev approval and
|
||||
merge, run "$NESTQUEST_CONTROL_HOME/scripts/nq-agent" with role approver in
|
||||
a separate session after the independent review. Save the Claude review output
|
||||
in a file under /tmp/opencode and give that file and its reviewed head to the
|
||||
approver. Never substitute a different model or reviewer silently.
|
||||
|
||||
When a feature is integrated into dev, reconcile Maestro, then immediately
|
||||
select and dispatch the next eligible feature. Feature completion is a
|
||||
transition, not the end of the controller's job. A release awaiting Joshua's
|
||||
main merge does not prevent work on independent eligible features.
|
||||
|
||||
End each invocation with exactly one final line using one of these values:
|
||||
|
||||
ORCHESTRATOR_STATE: CONTINUE
|
||||
ORCHESTRATOR_STATE: QUIESCENT
|
||||
ORCHESTRATOR_STATE: BLOCKED
|
||||
|
||||
Use CONTINUE when there is any eligible or in-flight work; include the feature,
|
||||
task, and next concrete action immediately above the final line. Use QUIESCENT
|
||||
only after verifying no eligible or in-flight work remains in Maestro and the
|
||||
repository. Use BLOCKED only when a specific decision or access is required;
|
||||
include evidence and the needed human action. If you cannot establish the
|
||||
state, use BLOCKED. Never emit a success claim based only on a child's summary.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
description: Run the NestQuest orchestration loop in this foreground terminal
|
||||
mode: primary
|
||||
model: openrouter/deepseek/deepseek-v4.1-flash
|
||||
permission:
|
||||
doom_loop: deny
|
||||
task: deny
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/tmp/opencode/*": allow
|
||||
"/home/overseer/.local/share/opencode/tool-output/*": allow
|
||||
---
|
||||
|
||||
You are the foreground NestQuest orchestrator. The user explicitly launched
|
||||
this session to run the development queue now. Read the supplied terminal
|
||||
orchestrator prompt and current CTXD project context, but follow the
|
||||
FOREGROUND TERMINAL RUNTIME for this session instead of CTXD's service runtime
|
||||
section.
|
||||
|
||||
Continuously reconcile Maestro, CTXD, Git, pull requests, checks, and child
|
||||
agent results. Execute the next useful transition in this same session. Do
|
||||
not finish after dispatch, review, remediation, or merge while eligible work
|
||||
remains. Use the installed role launchers for developer, reviewer, and
|
||||
approver isolation. Never start or wait for the systemd controller service.
|
||||
Never repeat an identical unchanged tool call three times; wait on the
|
||||
specific child or inspect newly changed evidence.
|
||||
|
||||
End only at a global terminal condition, an explicit user stop, a genuine
|
||||
global access/decision block with no other eligible work, or a host-forced
|
||||
yield. On a forced yield, return a continuation checkpoint that can be
|
||||
resumed in the same OpenCode session.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
description: Run the NestQuest orchestrator now in this OpenCode terminal
|
||||
agent: terminal-orchestrator
|
||||
---
|
||||
|
||||
Run the NestQuest project queue now in this foreground OpenCode session.
|
||||
Read the current CTXD prompt and Maestro state, but use the FOREGROUND
|
||||
TERMINAL RUNTIME instead of the service runtime. Reconcile repository and
|
||||
pull-request state, then execute CONTROLLER_TICK repeatedly until a global
|
||||
terminal condition, explicit user stop, genuine global block, or host-forced
|
||||
yield. Do not start or wait for the systemd controller.
|
||||
@@ -1,77 +0,0 @@
|
||||
# NestQuest foreground orchestrator
|
||||
|
||||
OpenCode 1.18.32 loads the named agents from `.opencode/agents/`. Their
|
||||
models match the current NestQuest CTXD prompt: DeepSeek V4.1 Flash for the
|
||||
foreground orchestrator and approver, and GLM 5.3 Flash for development.
|
||||
Run orchestration directly from a shell:
|
||||
|
||||
```bash
|
||||
nq-orchestrate
|
||||
nq-orchestrate mission.md
|
||||
cat mission.md | nq-orchestrate -
|
||||
nq-orchestrate --print-prompt > nestquest-orchestrator.md
|
||||
```
|
||||
|
||||
The launcher renders the project prompt with the foreground terminal runtime,
|
||||
stops the dormant legacy service, refuses immediately when another NestQuest
|
||||
TUI is open, and then replaces itself with OpenCode using
|
||||
`terminal-orchestrator`. There is no background polling process and no wait
|
||||
for a controller tick. Exit or interrupt the TUI to stop the foreground run;
|
||||
resume that OpenCode session when a host-forced yield needs continuation.
|
||||
|
||||
`scripts/nq-agent` launches developer and approver sessions with fixed agent
|
||||
definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the
|
||||
global idle-notification plugin does not notify on every controller tick.
|
||||
The approver's shell permission permits only the installed
|
||||
`nq-approve-merge` broker for Gitea. That broker requires exact-head
|
||||
Claude approval evidence, an exact-head Gitea approval by `review_agent`,
|
||||
the `dev` target, a mergeable PR, and passing configured checks before it
|
||||
requests a squash merge with Gitea's atomic `head_commit_id` guard.
|
||||
`scripts/nq-review` launches the independent
|
||||
Claude review in a noninteractive read-only session in the task worktree,
|
||||
checks the head again afterward, saves
|
||||
the output under `/tmp/opencode`, and posts the Gitea approval.
|
||||
The approver verifies the reviewed head again before a merge.
|
||||
|
||||
The terminal launcher uses a stable copy of this configuration and scripts
|
||||
at `/home/overseer/.local/share/nestquest-controller`; it does not depend on
|
||||
which task branch is checked out in the primary NestQuest directory. Install
|
||||
that copy, run `scripts/nq-controller --check`, and verify the OpenCode
|
||||
agents (terminal-orchestrator, developer, and approver)
|
||||
with `opencode debug agent <name>` while `OPENCODE_CONFIG_DIR` points at the
|
||||
installed `.opencode` directory. Install `scripts/nq-orchestrate` in
|
||||
`~/.local/bin/`. For the global slash command, also install
|
||||
`.opencode/agents/terminal-orchestrator.md` under
|
||||
`~/.config/opencode/agents/` and
|
||||
`deploy/opencode-global-commands/start-nestquest-development.md` under
|
||||
`~/.config/opencode/commands/`. The old `nestquest-controller.service` remains static and
|
||||
inactive only for migration safety; foreground launch stops it.
|
||||
|
||||
Inside an already-open OpenCode TUI, `/start-development` (or the global
|
||||
`/start-nestquest-development`) switches to the same foreground
|
||||
`terminal-orchestrator` behavior immediately. The shell launcher is the
|
||||
preferred entry point when supplying a mission prompt file.
|
||||
|
||||
`templates/ORCHESTRATOR-PROMPT.template.md` and
|
||||
`templates/nestquest.profile.json` make the current CTXD cycle prompt
|
||||
adaptable to another project. See `templates/README.md` before publishing a
|
||||
rendered prompt for a new project.
|
||||
|
||||
The CTXD preflight reads the configured HTTP/MCP server's NestQuest prompt.
|
||||
It deliberately does not inspect `/home/overseer/.ctx/ctxd.db`, which belongs
|
||||
to a different local store and lacks this project.
|
||||
|
||||
All four agent credentials are currently readable by the same Unix account.
|
||||
The Gitea review and merge gates enforce normal workflow sequencing, but
|
||||
they are not a hard isolation boundary against a process running as that
|
||||
account. Strong isolation requires separate OS identities and protected
|
||||
credentials for the controller, reviewer, and approver.
|
||||
|
||||
The terminal runtime repeatedly executes CONTROLLER_TICK within the foreground
|
||||
session. Neither a feature merge nor a progress report ends the run while
|
||||
eligible work remains. A host-forced yield returns a continuation checkpoint
|
||||
that can be resumed in the same OpenCode session.
|
||||
|
||||
Do not put tokens in this repository or in unit files. Existing OpenCode MCP
|
||||
connections supply CTXD and Maestro. Rotate those MCP tokens as a coordinated
|
||||
operation across every consumer before revoking the old values.
|
||||
@@ -159,50 +159,9 @@ Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
|
||||
|
||||
Run these steps in order to display the NestQuest panel on a TouchHub
|
||||
wall device. The panel is an HA Lovelace dashboard, so the kiosk browser
|
||||
is simply a Home Assistant client. The path below is zero-config: the
|
||||
integration registers its panel dashboard itself on setup, so no YAML
|
||||
and no hand-built views are involved.
|
||||
is simply a Home Assistant client.
|
||||
|
||||
1. **Install the integration and add the config entry.** Install
|
||||
NestQuest via HACS or manually (see Installation above) and restart
|
||||
Home Assistant. Then, in *Settings → Devices & Services*, click
|
||||
**Add Integration**, search for **NestQuest**, and submit the flow —
|
||||
it has no fields; it just creates the single config entry. Then open
|
||||
the entry, click **Configure**, and fill the panel-plane API
|
||||
connection in the options flow:
|
||||
|
||||
- **NestQuest API base URL** — the full `http://host:port` URL of
|
||||
your NestQuest API service (default `http://127.0.0.1:8000`).
|
||||
- **Panel service token** — the token the panel presents to the API
|
||||
service as its Bearer credential. It may stay empty until you have
|
||||
one; a pasted token must be unpadded.
|
||||
|
||||
The form's remaining fields (panel idle timeout, coordinator update
|
||||
interval, last-good snapshot staleness) have defaults that serve the
|
||||
panel and need no change here.
|
||||
|
||||
2. **The panel dashboard appears automatically.** On setup the
|
||||
integration registers one storage-mode Lovelace dashboard: a sidebar
|
||||
entry titled **NestQuest** at the URL path `/nestquest`, whose stored
|
||||
config is nothing but the **`custom:nestquest-party` strategy**
|
||||
(`strategy: { type: custom:nestquest-party }`). The strategy
|
||||
generates the views at render time from the household roster:
|
||||
|
||||
- **The Party** — the party-board view at `/nestquest/party`; the
|
||||
dashboard root `/nestquest` shows it as the first view.
|
||||
- one quest-log view per child at `/nestquest/<child-slug>`
|
||||
("Ada's Quest Log", …), in roster order.
|
||||
|
||||
Nothing is hand-configured: no YAML, no manual views, no
|
||||
`child_order`, no `quest_log_path`/`board_path` — the board and the
|
||||
quest logs compute their navigation paths from the dashboard's own
|
||||
URL — and every generated view is already in **Panel** layout (see
|
||||
the two constraints below). If the Lovelace surfaces are unavailable
|
||||
(for example when Lovelace runs in YAML mode), setup logs a warning
|
||||
and skips the registration; create the dashboard manually with the
|
||||
same strategy as described in the manual alternative below.
|
||||
|
||||
3. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
1. **Create a dedicated kiosk user.** In *Settings → People → Users*, add
|
||||
a new user for the wall device. Make it **non-admin**, and ensure it is
|
||||
not in the NestQuest admin allowlist. The allowlist blocks the NestQuest
|
||||
**admin** operations — `uncomplete_quest`, `create_quest_definition`,
|
||||
@@ -215,101 +174,66 @@ and no hand-built views are involved.
|
||||
Assistant user it may also reach other permitted Home Assistant entities
|
||||
and services.
|
||||
|
||||
4. **Open the dashboard and point TouchHub at it.** Open
|
||||
`http://<home-assistant>/nestquest` in a browser first and check the
|
||||
party board renders with your children's crests. Then, in the
|
||||
TouchHub launcher, add the **Home Assistant Lovelace** app to the
|
||||
dock and paste the dashboard URL from step 2 — the dashboard root,
|
||||
`/nestquest` (TouchHub loads that URL directly as the kiosk landing
|
||||
page). Set TouchHub **Auto-Return** to a value GREATER than the
|
||||
quest log's `idle_return_seconds` (default 40s) — recommend **60s or
|
||||
higher**. If Auto-Return is at or below 40s, TouchHub returns to its
|
||||
launcher before the quest log's idle return can navigate back to the
|
||||
party board.
|
||||
2. **Build a dashboard with only the NestQuest panel cards.** Create a
|
||||
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
|
||||
Dashboards → Edit → URL slug). A functional panel needs **two** views
|
||||
in that dashboard, each with its own card config from
|
||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
||||
paths (the path segment after the dashboard slug):
|
||||
|
||||
5. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 3, and leave that session logged in.
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return),
|
||||
`confirm_timeout_seconds`, and `complete_screen_seconds`. The quest
|
||||
log reads the selected child's slug from the view's URL path, so the
|
||||
board → log hop is a plain Lovelace navigation.
|
||||
|
||||
Each view's layout MUST be set to **Panel** (Edit Dashboard → pencil on the
|
||||
view → View type: Panel). Home Assistant's default Masonry layout constrains
|
||||
the 1080px-tall NestQuest card to a narrow column; Panel (single-card)
|
||||
layout is what renders the card full-screen at 1920×1080.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
3. **Point TouchHub at it.** In the TouchHub launcher, add the **Home
|
||||
Assistant Lovelace** app to the dock and paste the dashboard URL from
|
||||
step 2 — the party-board view URL, e.g. `/nestquest/board` (TouchHub
|
||||
loads that URL directly as the kiosk landing page). Set TouchHub
|
||||
**Auto-Return** to a value GREATER than the quest log's
|
||||
`idle_return_seconds` (default 40s) — recommend **60s or higher**. If
|
||||
Auto-Return is at or below 40s, TouchHub returns to its launcher before
|
||||
the quest log's idle return can navigate back to the party board.
|
||||
|
||||
4. **Log the kiosk session in.** Sign in on the device as the dedicated
|
||||
kiosk user from step 1, and leave that session logged in.
|
||||
|
||||
TouchHub loads the dashboard URL directly. The NestQuest cards are normal
|
||||
Lovelace cards, not an iframe, so no Home Assistant framing setting is
|
||||
needed.
|
||||
|
||||
### The two constraints
|
||||
|
||||
- **Panel layout.** Every view must be in **Panel** (single-card)
|
||||
layout: Home Assistant's default Masonry layout constrains the
|
||||
1080px-tall NestQuest card to a narrow column, while Panel renders the
|
||||
card full-screen at 1920×1080. The zero-config path already satisfies
|
||||
the requirement — the strategy generates every view as
|
||||
`type: panel` — while the manual alternative below requires setting
|
||||
the layout by hand on each view (Edit Dashboard → pencil on the view
|
||||
→ View type: Panel).
|
||||
|
||||
- **TouchHub Auto-Return.** TouchHub's **Auto-Return** must be greater
|
||||
than the quest log's `idle_return_seconds` (default 40s) — recommend
|
||||
**60s or higher** — so the launcher does not reclaim the wall device
|
||||
before the quest log's own idle return navigates back to the party
|
||||
board.
|
||||
|
||||
### Manual alternative: build the dashboard by hand
|
||||
|
||||
If you prefer not to use the zero-config dashboard above — or its
|
||||
registration was skipped — configure a Lovelace dashboard whose **URL
|
||||
slug is `nestquest`** (Settings → Dashboards → Edit → URL slug)
|
||||
yourself. Two options, in decreasing order of convenience:
|
||||
|
||||
- **Use the strategy.** Give the dashboard
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
type: custom:nestquest-party
|
||||
```
|
||||
|
||||
and no views of your own — the strategy generates the party-board and
|
||||
quest-log views (each already in Panel layout) from the household
|
||||
roster at render time. Optional `url_path` and `weather_entity` keys
|
||||
may sit beside `strategy:`.
|
||||
|
||||
- **Build the two views by hand.** A functional panel needs **two**
|
||||
views in that dashboard, each with its own card config from
|
||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
||||
paths (the path segment after the dashboard slug):
|
||||
|
||||
- **View 1 path `board`** (URL `/nestquest/board`) — party board
|
||||
(`type: custom:nestquest-party-board-card`): the household quest
|
||||
board. Set `weather_entity` (optional; the dock falls back to
|
||||
date/time), `quest_log_path: /nestquest/log` (the view the crest tap
|
||||
navigates to), and `child_order: [slug1, slug2, slug3]` listing each
|
||||
child's slug in display order.
|
||||
|
||||
- **View 2 path `log`** (URL `/nestquest/log`) — quest log
|
||||
(`type: custom:nestquest-quest-log-card`): the per-child quest screen
|
||||
the party board hops to. Set `board_path: /nestquest/board` (the
|
||||
party-board view to return to), `weather_entity`,
|
||||
`idle_return_seconds` (must be shorter than TouchHub Auto-Return —
|
||||
see the two constraints above), `confirm_timeout_seconds`, and
|
||||
`complete_screen_seconds`. The quest log reads the selected child's
|
||||
slug from the view's URL path, so the board → log hop is a plain
|
||||
Lovelace navigation.
|
||||
|
||||
The dashboard contains only the NestQuest panel card for each view —
|
||||
no other cards, no admin controls. Each view's layout MUST be set to
|
||||
**Panel** — see the two constraints above for why; the strategy
|
||||
generates it for you, hand-built views do not get it for free.
|
||||
|
||||
The `quest_log_path` and `board_path` values must match the dashboard
|
||||
slug and view paths above: `/<slug>/<view>`. If you chose `nestquest`
|
||||
as the dashboard slug with view paths `board` and `log`, the card
|
||||
configs are already correct. If you pick different names, substitute
|
||||
your actual paths in **both** card configs — e.g. a dashboard slug
|
||||
`chores` with view paths `main` and `history` needs
|
||||
`quest_log_path: /chores/history` and `board_path: /chores/main`.
|
||||
Navigation between the two views fails (the crest tap or back button
|
||||
lands on a 404) if the card config paths do not match the dashboard
|
||||
slug and view paths exactly.
|
||||
|
||||
See `design/ENTITIES-AND-SERVICES.md` §4 for the full card YAML.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
|
||||
+21
-129
@@ -1,135 +1,27 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Version 0.6.0 — 2026-09-23
|
||||
## Unreleased
|
||||
|
||||
### Scope
|
||||
|
||||
Sixth release of NestQuest, promoting the re-imagined architecture
|
||||
(Features 15, 16, 18 and 20) and the foreground development-orchestration
|
||||
tooling from `dev` onto 0.5.0. This is a major architectural change: the
|
||||
Home Assistant integration becomes a thin client of a standalone NestQuest
|
||||
API service that owns the database.
|
||||
|
||||
- **Feature 15 — Domain Core Extraction:** all domain code (schema,
|
||||
migrations, database access, the DAOs, the pure recurrence/presence
|
||||
engines, materialization, completion, the SSE-free event payload
|
||||
builders, the panel snapshot builder, the children/definitions/allowlist
|
||||
business layers, and an explicit settings object) now lives in a
|
||||
Home-Assistant-free package bundled inside the integration at
|
||||
`custom_components/nestquest/core/`. The SQLite schema and migration
|
||||
versions are unchanged, so an existing `nestquest.db` opens without
|
||||
migration. Covered by a no-Home-Assistant end-to-end smoke test.
|
||||
(Tasks d0b691d8, 510c1f78, 4dd8f870, 628473ad.)
|
||||
- **Feature 16 — NestQuest API Service:** a standalone FastAPI service
|
||||
(`api/`) that imports `core/` and is the only writer of the database.
|
||||
Panel plane (`/api/v1/panel/*`) is authenticated by a static service
|
||||
token: household snapshot and an idempotent complete route. Admin plane
|
||||
(`/api/v1/admin/*`): children, quest definitions, presence
|
||||
schedule/override, uncomplete, regenerate, history query + CSV export,
|
||||
and settings — authenticated by an Authentik OIDC JWT (issuer, audience,
|
||||
JWKS signature and expiry) plus membership in `nestquest-admins`, with
|
||||
the panel service token rejected outright. A server-sent-events stream
|
||||
publishes the quest-completed, quest-uncompleted, quest-missed and
|
||||
child-day-complete transitions; the missed sweep runs in the API with a
|
||||
cross-process-safe watermark. `/health` and the OpenAPI document are
|
||||
served. (Tasks 042dfc5c, ae7e90ae, 1860ef76, 4d7bae00, 44c93cfd,
|
||||
e70e6bfb, 0f2afb29, e4845baa, b586a2f1, e4a9d31b, da0226b3, 2d2dda53,
|
||||
2b3de7e5, 9047a97b, 9a118da9, 058c7b69, a232c4f5.)
|
||||
- **Feature 18 — HA Integration as API Client:** the integration no longer
|
||||
owns a database. It polls `GET /api/v1/panel/snapshot` and builds the
|
||||
existing sensors (entity ids and per-child instance payload shape
|
||||
unchanged), subscribes to the SSE stream and re-fires the four HA bus
|
||||
events, proxies `nestquest.complete_quest` to the panel complete route,
|
||||
and caches the last-good snapshot so a brief API outage does not blank
|
||||
the panel. The HA admin services and the service permission gate are
|
||||
removed; only the panel completion path remains. (Tasks 37d5e806,
|
||||
6e70d166, b1ed9c45, 47c7c90e, d25b875f, d04f5bf3, ad67fc44.)
|
||||
- **Feature 20 — Zero-Config Panel Dashboard:** the panel installs with no
|
||||
hand-written card YAML and no manual dashboard creation. The cards
|
||||
auto-discover children from a household entity attribute (ordered by the
|
||||
API's `sort_order`), a Lovelace dashboard strategy generates the
|
||||
party-board view and one quest-log view per child at render time with
|
||||
computed navigation paths, and the integration registers the dashboard
|
||||
on first setup. Navigation and the panel transitions match the design
|
||||
mockups. (Tasks 1b6d91d2, c859af23, c2d4dd8e, 107b8fbe, 50067c1f,
|
||||
fc69c940, 67775be9.)
|
||||
- **Development orchestration tooling:** durable OpenCode controller and
|
||||
role launchers, the foreground terminal orchestrator, the independent
|
||||
review/approval gates, and the automatic release-preparation policy.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
- A running NestQuest API service, reachable at the base URL and panel
|
||||
service token configured in the integration options. The API service
|
||||
source ships in `api/`; there is no container image, compose file or
|
||||
deployment runbook in this release, and Feature 17 — the deployment
|
||||
(TLS reverse proxy, Authentik, network separation, backups) — is **not**
|
||||
part of this release and must be completed before the integration is
|
||||
functional. See `docs/ARCHITECTURE.md` for the target design.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- **The HA integration no longer owns a database.** It reads exclusively
|
||||
from the NestQuest API. The local `nestquest.db` is no longer opened,
|
||||
migrated or written by the integration (the file is left untouched on
|
||||
disk). Without a reachable API the entities become unavailable after the
|
||||
configured staleness threshold; the panel serves the last-good snapshot
|
||||
in the meantime.
|
||||
- **HA admin services are removed** (`uncomplete`, quest-definition
|
||||
create/update/set-active, presence pattern/override, CSV export,
|
||||
`manage_child`, `regenerate`) and the HA-side permission gate is gone.
|
||||
Admin operations move to the API, and — once Feature 19 lands — the
|
||||
admin PWA. Any automation that called an admin service must be
|
||||
repointed at the API.
|
||||
- **Logger namespaces moved** under `custom_components.nestquest.core.*`:
|
||||
the migrations runner now logs under
|
||||
`custom_components.nestquest.core.migrations` (previously
|
||||
`custom_components.nestquest.migrations`), and the children registry and
|
||||
admin allowlist log under `custom_components.nestquest.core.children` and
|
||||
`custom_components.nestquest.core.admin_allowlist`. The parent
|
||||
`custom_components.nestquest` logger still catches all three; only users
|
||||
who pinned a leaf logger name by its old spelling must update.
|
||||
- **The panel installs with no configuration.** `child_order`,
|
||||
`quest_log_path` and `board_path` are no longer required (an explicit
|
||||
value still wins). The integration registers a `custom:nestquest-party`
|
||||
dashboard on setup; point TouchHub at it.
|
||||
- **Schema is unchanged at version 7** — no migration runs on upgrade.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **Feature 17 (API deployment and identity) is not in this release.** The
|
||||
API service source ships in `api/`, but no container image, compose
|
||||
file, TLS reverse proxy, Authentik application/provider/`nestquest-admins`
|
||||
group, proven network separation of the panel routes, or backup/restore
|
||||
is included. Feature 17's containerization groundwork was completed on
|
||||
its own feature branch, which is **not** merged into `dev` and therefore
|
||||
is not part of this release. Until that deployment is completed the
|
||||
integration has nothing to talk to.
|
||||
- **Feature 19 (admin PWA) is not in this release.** There is no admin UI
|
||||
in 0.6.0; admin operations are reachable only through the API directly.
|
||||
- Moving the household database from Home Assistant to the API box is a
|
||||
manual file copy (the schema is unchanged).
|
||||
- Single household; single API service.
|
||||
|
||||
### Rollback
|
||||
|
||||
Schema version is unchanged (7), so no migration is involved in either
|
||||
direction. Nevertheless **back up `nestquest.db` (plus `-wal`/`-shm`)
|
||||
before upgrading**: 0.6.0 never touches it, and a downgrade to 0.5.0
|
||||
resumes using exactly that file.
|
||||
|
||||
**HACS installs:**
|
||||
|
||||
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.5.0).
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Manual installs:**
|
||||
|
||||
1. Replace `custom_components/nestquest/` with the 0.5.0 tree.
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Repository operators:** git-revert the 0.6.0 release merge on `main`.
|
||||
- **Storage layer extraction (task d0b691d8):** the SQLite storage layer
|
||||
(schema, migrations, db, store, const, recurrence, DAOs) is bundled
|
||||
under `custom_components/nestquest/core/`, with the integration's
|
||||
original module names preserved as re-export shims.
|
||||
- **Logger namespace change (breaking for logging configuration):** the
|
||||
migrations runner now logs under
|
||||
`custom_components.nestquest.core.migrations` instead of
|
||||
`custom_components.nestquest.migrations`. Users who tuned the old
|
||||
logger name (e.g. in `logger.yaml` or `configuration.yaml`) must
|
||||
update the entry to keep that level, as the old name no longer
|
||||
receives migration log output.
|
||||
- **Additional logger namespace moves:** the children registry and the
|
||||
admin allowlist business layers now log under
|
||||
`custom_components.nestquest.core.children` and
|
||||
`custom_components.nestquest.core.admin_allowlist` respectively
|
||||
(moved with the engines/business-layer extraction, task 510c1f78).
|
||||
The parent `custom_components.nestquest` logger still catches both,
|
||||
so users who tune the parent rather than the leaf names are
|
||||
unaffected; only users who pinned a leaf logger name by its old
|
||||
spelling need to update.
|
||||
|
||||
## Version 0.5.0 — 2026-09-18
|
||||
|
||||
|
||||
+18
-420
@@ -30,10 +30,10 @@ hand-rolled here), and the call goes to
|
||||
``set_quest_definition_active``. The title policy, the window-name
|
||||
and due-time policy, the assignee checks, the whole-set window
|
||||
replacement and the never-hard-delete policy (deactivation is the only
|
||||
removal path) all live in the core. The edit route forwards only
|
||||
SUPPLIED fields (``exclude_unset``), like the children edit; a supplied
|
||||
``assignee_child_ids`` list REPLACES the whole assignee set inside the
|
||||
core edit's one transaction (and its one regeneration).
|
||||
removal path) all live in the core. Assignment is NOT settable
|
||||
through the API: the core's edit path takes no assignees. The edit
|
||||
route forwards only SUPPLIED fields (``exclude_unset``), like the
|
||||
children edit.
|
||||
|
||||
The presence routes follow the same pattern over
|
||||
:mod:`nestquest_core.presence_management` — the ONE presence write
|
||||
@@ -58,17 +58,6 @@ layer the HA services share, so the two planes cannot drift:
|
||||
(children are never hard-deleted; overrides are deletable by
|
||||
design) and regenerates that override's child. It answers
|
||||
``{"status": "ok"}``.
|
||||
- ``GET /children/{child_id}/presence-schedule`` reads the child's
|
||||
schedule through ONE ``get_presence_schedule`` call, wrapped as
|
||||
``{"schedule": ...}`` in the PUT response's shape — ``null`` when the
|
||||
child has none (present every day), never a 404; an unknown child is
|
||||
404.
|
||||
- ``GET /presence-overrides`` lists the household's overrides through
|
||||
ONE ``list_presence_overrides`` call, wrapped as
|
||||
``{"overrides": [...]}`` in the POST response's shape, ordered by
|
||||
start date, child id, id; the optional ``child_id``, ``start`` and
|
||||
``end`` query filters (strict dates, ``end >= start``) are the
|
||||
core's to validate.
|
||||
|
||||
The uncomplete and regenerate routes (task da0226b3) follow the same
|
||||
pattern over :mod:`nestquest_core.completion` and
|
||||
@@ -127,22 +116,6 @@ owns every rule so the two routes cannot drift:
|
||||
a ``Content-Disposition`` attachment filename and the documented,
|
||||
stable header row (Admin spec §5 event-row fields).
|
||||
|
||||
The snapshot route (task ef3f4877) is the admin-plane twin of the
|
||||
panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab —
|
||||
the PWA is internet-facing and never holds the panel service token
|
||||
(D-012), so it reads the same snapshot here:
|
||||
|
||||
- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into
|
||||
:func:`nestquest_core.snapshot.build_snapshot` and shapes each
|
||||
child's instances through
|
||||
:func:`nestquest_core.snapshot.instance_payload` with
|
||||
``include_missed=True`` — admin payloads KEEP missed instances with
|
||||
their ``missed`` state (D-009), where the panel omits them. The
|
||||
body is the panel's documented shape (``today_iso``, ``cycle_day``,
|
||||
``children`` with the per-child presence, rollup counts and
|
||||
``instances``); the builder, the state derivation and the shaping
|
||||
all live in the core.
|
||||
|
||||
The settings routes (task 2b3de7e5) follow the same pattern over
|
||||
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
|
||||
store. The API now OWNS settings in the database (a validated JSON
|
||||
@@ -183,33 +156,6 @@ integration's rollover listener shares:
|
||||
read-only-over-the-domain-tables guarantee all live in the core; the
|
||||
response reports how many transitions were published.
|
||||
|
||||
The occurrence-preview route (task 5f843564) lets the admin PWA's
|
||||
Definitions edit sheet show the next dates a rule fires on WITHOUT
|
||||
reimplementing the recurrence rules client-side:
|
||||
|
||||
- ``POST /quest-definitions/occurrences-preview`` takes the SAME
|
||||
``rule`` object create/edit take (:class:`AdminRuleRequest`, built
|
||||
through the SAME :func:`_rule_from_request` / core ``from_dict``
|
||||
validation, so a rejected rule is 422 exactly as on create), an
|
||||
optional ``start_date`` (default: the API host's local ``today``,
|
||||
ONE :func:`_local_now` clock read) and an optional ``count`` (1..50,
|
||||
default 10). The dates come from ONE
|
||||
:func:`nestquest_core.recurrence.occurrences_between` call — the
|
||||
same engine the materializer walks — so the preview can only agree
|
||||
with what would be generated. The scan is BOUNDED server-side: the
|
||||
window is ``[start_date, start_date + 366 days]`` inclusive,
|
||||
whatever the caller sends, so every rule shape (a yearly rule
|
||||
included) shows at least its next occurrence, and a caller can never
|
||||
ask for an unbounded walk. Any strict ``start_date`` is accepted
|
||||
rather than clamped or range-limited — the fixed window already
|
||||
bounds the work — EXCEPT one whose window would reach 9999-12-31,
|
||||
which is 422 (the core's day walk cannot step past the calendar's
|
||||
last date). ``start_date`` strictness
|
||||
(``YYYY-MM-DD``) is the core's own date validator; a non-strict or
|
||||
unparseable value is 422. A ``count`` outside 1..50 is the body
|
||||
model's 422 before any handler code runs. Nothing is read or
|
||||
written: the route touches no database.
|
||||
|
||||
Error mapping (every children, quest-definition and presence route,
|
||||
deliberately narrow):
|
||||
|
||||
@@ -220,9 +166,8 @@ deliberately narrow):
|
||||
set_active on an unknown id) is mapped to 404.
|
||||
- A core ``ValueError`` reporting a NON-EXISTENT DEFINITION (edit or
|
||||
set_active on an unknown id — the core prefixes that error with
|
||||
``definition_id:``) is mapped to 404. Create's and edit's
|
||||
rejected-assignee error names a child from the BODY, not a path id,
|
||||
so it maps to 422.
|
||||
``definition_id:``) is mapped to 404. Create's rejected-assignee
|
||||
error names a child from the BODY, not a path id, so it maps to 422.
|
||||
- Every OTHER core ``ValueError`` — an empty display name, an explicit
|
||||
``null`` colour/avatar_ref (clearing is not supported), a reorder
|
||||
list that is not a complete permutation of the children (missing,
|
||||
@@ -290,13 +235,7 @@ import datetime
|
||||
from typing import Annotated, Literal, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
StrictBool,
|
||||
StrictInt,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
from api import transitions as api_transitions
|
||||
from api.auth import require_admin
|
||||
@@ -315,7 +254,6 @@ from api.nestquest_core import (
|
||||
core_recurrence,
|
||||
core_settings,
|
||||
core_settings_store,
|
||||
core_snapshot,
|
||||
core_sweep,
|
||||
)
|
||||
|
||||
@@ -408,18 +346,11 @@ class AdminQuestDefinitionResponse(BaseModel):
|
||||
description: str | None
|
||||
icon: str | None
|
||||
is_active: bool
|
||||
skip_on_away: bool
|
||||
rule: AdminRuleResponse
|
||||
assignees: list[AdminAssigneeResponse]
|
||||
windows: list[AdminDefinitionWindowResponse]
|
||||
|
||||
|
||||
class AdminQuestDefinitionListResponse(BaseModel):
|
||||
"""The definitions list: every definition, active AND inactive, by id."""
|
||||
|
||||
definitions: list[AdminQuestDefinitionResponse]
|
||||
|
||||
|
||||
class AdminChildCreateRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/admin/children``.
|
||||
|
||||
@@ -490,67 +421,24 @@ class AdminRuleRequest(BaseModel):
|
||||
end_date: str | None = None
|
||||
|
||||
|
||||
#: Upper bound on ``count`` for the occurrence-preview route.
|
||||
_PREVIEW_MAX_COUNT = 50
|
||||
|
||||
#: Days after ``start_date`` the occurrence-preview window spans
|
||||
#: (inclusive), so a yearly rule always shows its next occurrence.
|
||||
_PREVIEW_WINDOW_DAYS = 366
|
||||
|
||||
|
||||
class AdminOccurrencesPreviewRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/admin/quest-definitions/occurrences-preview``.
|
||||
|
||||
``rule`` is the SAME object create/edit take. ``start_date`` is an
|
||||
optional strict ``YYYY-MM-DD`` (validated by the core; default the
|
||||
API host's today). ``count`` is a strict JSON integer in
|
||||
1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before any handler
|
||||
code runs.
|
||||
"""
|
||||
|
||||
rule: AdminRuleRequest
|
||||
start_date: str | None = None
|
||||
count: StrictInt = 10
|
||||
|
||||
@field_validator("count")
|
||||
@classmethod
|
||||
def _count_in_range(cls, value: int) -> int:
|
||||
"""Reject a count outside 1.._PREVIEW_MAX_COUNT."""
|
||||
if not 1 <= value <= _PREVIEW_MAX_COUNT:
|
||||
raise ValueError(
|
||||
f"count must be between 1 and {_PREVIEW_MAX_COUNT}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class AdminOccurrencesPreviewResponse(BaseModel):
|
||||
"""The next occurrence dates (``YYYY-MM-DD``, ascending)."""
|
||||
|
||||
dates: list[str]
|
||||
|
||||
|
||||
class AdminQuestDefinitionCreateRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/admin/quest-definitions``.
|
||||
|
||||
``title``, ``rule``, ``assignee_child_ids`` and ``windows`` are
|
||||
required (the core rejects an empty title, a non-list or empty
|
||||
assignee/window list, and unknown window names); ``description``
|
||||
and ``icon`` are optional and default to NULL through the core, and
|
||||
``skip_on_away`` is optional and defaults to true (no instance on a
|
||||
date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item
|
||||
and ``icon`` are optional and default to NULL through the core.
|
||||
``windows`` entries are a window name (``morning``) or a two-item
|
||||
``[window, due_time]`` pair whose due time is a strict 24-hour
|
||||
HH:MM (or ``null`` for none) — both forms the core normalizes.
|
||||
Assignee ids are strict JSON integers: a boolean, float or numeric
|
||||
string is 422, never coerced to a child id.
|
||||
"""
|
||||
|
||||
title: str
|
||||
rule: AdminRuleRequest
|
||||
assignee_child_ids: list[StrictInt]
|
||||
assignee_child_ids: list[int]
|
||||
windows: list[str | tuple[str, str | None]]
|
||||
description: str | None = None
|
||||
icon: str | None = None
|
||||
skip_on_away: StrictBool = True
|
||||
|
||||
|
||||
class AdminQuestDefinitionEditRequest(BaseModel):
|
||||
@@ -562,13 +450,10 @@ class AdminQuestDefinitionEditRequest(BaseModel):
|
||||
field stays unchanged. An explicit ``null`` ``description``/
|
||||
``icon`` IS passed and CLEARS the stored value (the core supports
|
||||
clearing here, unlike the children edit); an explicit ``null``
|
||||
``rule``, ``windows`` or ``assignee_child_ids`` is passed and
|
||||
rejected by the core. A supplied ``windows`` list REPLACES the
|
||||
whole window set, and a supplied ``assignee_child_ids`` list
|
||||
REPLACES the whole assignee set (non-empty, every id an existing
|
||||
active child — create's contract; a rejection changes nothing). An
|
||||
omitted (or ``null``) ``skip_on_away`` keeps the stored value.
|
||||
Assignee ids are strict JSON integers, as on create.
|
||||
``rule`` or ``windows`` is passed and rejected by the core. A
|
||||
supplied ``windows`` list REPLACES the whole window set, and
|
||||
``assignees`` are deliberately absent — assignment is not settable
|
||||
through this route.
|
||||
"""
|
||||
|
||||
title: str | None = None
|
||||
@@ -576,8 +461,6 @@ class AdminQuestDefinitionEditRequest(BaseModel):
|
||||
icon: str | None = None
|
||||
rule: AdminRuleRequest | None = None
|
||||
windows: list[str | tuple[str, str | None]] | None = None
|
||||
skip_on_away: StrictBool | None = None
|
||||
assignee_child_ids: list[StrictInt] | None = None
|
||||
|
||||
|
||||
class AdminQuestDefinitionActiveRequest(BaseModel):
|
||||
@@ -650,22 +533,6 @@ class AdminPresenceOverrideResponse(BaseModel):
|
||||
note: str | None
|
||||
|
||||
|
||||
class AdminPresenceScheduleReadResponse(BaseModel):
|
||||
"""The ``GET /children/{child_id}/presence-schedule`` payload.
|
||||
|
||||
``schedule`` is ``None`` when the child has no schedule — the child
|
||||
is present every day (Feature 05); absence is state, not an error.
|
||||
"""
|
||||
|
||||
schedule: AdminPresenceScheduleResponse | None
|
||||
|
||||
|
||||
class AdminPresenceOverrideListResponse(BaseModel):
|
||||
"""The ``GET /presence-overrides`` payload: the matching overrides."""
|
||||
|
||||
overrides: list[AdminPresenceOverrideResponse]
|
||||
|
||||
|
||||
class AdminUncompleteResponse(BaseModel):
|
||||
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
|
||||
|
||||
@@ -816,59 +683,6 @@ class AdminMissedSweepResponse(BaseModel):
|
||||
fired: int
|
||||
|
||||
|
||||
class AdminInstancePayload(BaseModel):
|
||||
"""One instance in the admin snapshot payload.
|
||||
|
||||
Mirrors :func:`nestquest_core.snapshot.instance_payload` with
|
||||
``include_missed=True``: ``state`` is ``open`` | ``completed`` |
|
||||
``missed`` (admin payloads keep missed instances, D-009),
|
||||
``on_time`` passes the completion event's flag through verbatim
|
||||
(``None`` unless completed), and ``completed_at`` is the event's
|
||||
UTC timestamp when completed.
|
||||
"""
|
||||
|
||||
id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_time: str | None
|
||||
state: str
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
on_time: bool | None
|
||||
|
||||
|
||||
class AdminChildSnapshotPayload(BaseModel):
|
||||
"""One child's day: presence, rollup counts, and today's instances."""
|
||||
|
||||
child_id: int
|
||||
child_name: str
|
||||
present: bool
|
||||
#: ISO date of the child's next present day while they are away;
|
||||
#: ``None`` when the child is present today.
|
||||
next_present: str | None
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
instances: list[AdminInstancePayload]
|
||||
|
||||
|
||||
class AdminSnapshotResponse(BaseModel):
|
||||
"""Today's whole household snapshot for the admin Today tab.
|
||||
|
||||
The panel snapshot's shape (``today_iso``, ``cycle_day``,
|
||||
``children`` in the household's sort order), with missed instances
|
||||
kept in each child's ``instances``.
|
||||
"""
|
||||
|
||||
today_iso: str
|
||||
cycle_day: int
|
||||
children: list[AdminChildSnapshotPayload]
|
||||
|
||||
|
||||
#: 404 detail for a child id the core layer reports as non-existent.
|
||||
_CHILD_NOT_FOUND_DETAIL = "Child not found"
|
||||
|
||||
@@ -1031,40 +845,6 @@ def _raise_sweep_error(error: ValueError) -> NoReturn:
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
|
||||
|
||||
def _raise_preview_error(error: ValueError) -> NoReturn:
|
||||
"""Map a core recurrence ``ValueError`` from the preview route to 422.
|
||||
|
||||
The preview route has no path ids, so it has no 404 case: a
|
||||
rejected rule (:class:`~nestquest_core.recurrence.RuleValidationError`)
|
||||
or a non-strict ``start_date`` is 422 with the core's message as
|
||||
the detail. Only ``ValueError`` is caught, so an unexpected
|
||||
failure re-raises rather than becoming a 4xx.
|
||||
"""
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
|
||||
|
||||
def _preview_window_end(start_date: str) -> str:
|
||||
"""Return the preview window's inclusive end for ``start_date``.
|
||||
|
||||
``start_date`` + :data:`_PREVIEW_WINDOW_DAYS`. Parsing here only
|
||||
locates the window; the STRICT ``YYYY-MM-DD`` check stays the
|
||||
core's — :func:`~nestquest_core.recurrence.occurrences_between`
|
||||
re-validates the same string and rejects any lenient form this
|
||||
parse accepted. Raises ``ValueError`` for an unparseable value and
|
||||
for a start whose window would reach the calendar's last date (the
|
||||
core's day walk steps one day PAST its end, which overflows there).
|
||||
"""
|
||||
start = datetime.date.fromisoformat(start_date)
|
||||
if datetime.date.max - start <= datetime.timedelta(
|
||||
days=_PREVIEW_WINDOW_DAYS
|
||||
):
|
||||
raise ValueError(
|
||||
f"start_date {start_date!r} is too close to the end of the "
|
||||
"calendar for a preview window"
|
||||
)
|
||||
return (start + datetime.timedelta(days=_PREVIEW_WINDOW_DAYS)).isoformat()
|
||||
|
||||
|
||||
def _local_now() -> datetime.datetime:
|
||||
"""Return the API host's local time as ONE timezone-aware clock read.
|
||||
|
||||
@@ -1107,7 +887,6 @@ def _quest_definition_response(
|
||||
description=bundle.definition.description,
|
||||
icon=bundle.definition.icon,
|
||||
is_active=bundle.definition.is_active,
|
||||
skip_on_away=bundle.definition.skip_on_away,
|
||||
rule=_rule_response(bundle.rule),
|
||||
assignees=[
|
||||
AdminAssigneeResponse(id=child.id, display_name=child.display_name)
|
||||
@@ -1319,32 +1098,6 @@ async def admin_reorder_children(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/quest-definitions",
|
||||
summary="List quest definitions",
|
||||
response_model=AdminQuestDefinitionListResponse,
|
||||
)
|
||||
async def admin_list_quest_definitions(
|
||||
request: Request,
|
||||
) -> AdminQuestDefinitionListResponse:
|
||||
"""Return every quest definition in rising id order.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: one
|
||||
:func:`nestquest_core.quest_definitions.list_all_definitions` call,
|
||||
active AND inactive definitions included (the admin screen manages
|
||||
both), each serialized exactly as the create/edit responses are.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
bundles = await core_quest_definitions.list_all_definitions(
|
||||
state.database
|
||||
)
|
||||
return AdminQuestDefinitionListResponse(
|
||||
definitions=[_quest_definition_response(bundle) for bundle in bundles]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/quest-definitions",
|
||||
summary="Create a quest definition",
|
||||
@@ -1379,49 +1132,12 @@ async def admin_create_quest_definition(
|
||||
body.windows,
|
||||
description=body.description,
|
||||
icon=body.icon,
|
||||
skip_on_away=body.skip_on_away,
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_quest_definition_error(error)
|
||||
return _quest_definition_response(bundle)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/quest-definitions/occurrences-preview",
|
||||
summary="Preview the next occurrence dates of a schedule rule",
|
||||
response_model=AdminOccurrencesPreviewResponse,
|
||||
)
|
||||
async def admin_preview_occurrences(
|
||||
body: AdminOccurrencesPreviewRequest,
|
||||
) -> AdminOccurrencesPreviewResponse:
|
||||
"""Return the first ``count`` dates the supplied rule fires on.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: the ``rule``
|
||||
becomes ONE :class:`~nestquest_core.recurrence.ScheduleRule`
|
||||
(:func:`_rule_from_request`, the create/edit path), and the dates
|
||||
come from ONE :func:`nestquest_core.recurrence.occurrences_between`
|
||||
call over the bounded window ``[start_date, start_date + 366
|
||||
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
|
||||
read — truncated to ``count``. Every core ``ValueError`` is 422
|
||||
(:func:`_raise_preview_error`). No database access.
|
||||
"""
|
||||
start_date = (
|
||||
body.start_date
|
||||
if body.start_date is not None
|
||||
else _local_now().date().isoformat()
|
||||
)
|
||||
try:
|
||||
rule = _rule_from_request(body.rule)
|
||||
dates = core_recurrence.occurrences_between(
|
||||
rule, start_date, _preview_window_end(start_date)
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_preview_error(error)
|
||||
return AdminOccurrencesPreviewResponse(dates=dates[: body.count])
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/quest-definitions/{definition_id}",
|
||||
summary="Edit a quest definition",
|
||||
@@ -1441,12 +1157,10 @@ async def admin_edit_quest_definition(
|
||||
field is never passed to
|
||||
:func:`nestquest_core.quest_definitions.edit_quest_definition` and
|
||||
stays unchanged; a supplied ``windows`` list replaces the WHOLE
|
||||
window set in the core, a supplied ``assignee_child_ids`` list
|
||||
replaces the WHOLE assignee set in the same core transaction, and a
|
||||
supplied ``rule`` is rebuilt into a ScheduleRule
|
||||
(:func:`_rule_from_request`, validated by the model). An explicit
|
||||
``null`` ``rule``/``windows``/``assignee_child_ids`` reaches the
|
||||
core as None and is rejected there (422), while an explicit ``null``
|
||||
window set in the core, and a supplied ``rule`` is rebuilt into a
|
||||
ScheduleRule (:func:`_rule_from_request`, validated by the model).
|
||||
An explicit ``null`` ``rule``/``windows`` reaches the core as None
|
||||
and is rejected there (422), while an explicit ``null``
|
||||
``description``/``icon`` clears the stored value — the core edit
|
||||
path supports clearing, unlike the children edit. An unknown
|
||||
definition id is mapped to 404 and any other core ``ValueError``
|
||||
@@ -1608,75 +1322,6 @@ async def admin_delete_presence_override(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/children/{child_id}/presence-schedule",
|
||||
summary="Read a child's repeating presence schedule",
|
||||
response_model=AdminPresenceScheduleReadResponse,
|
||||
)
|
||||
async def admin_get_presence_schedule(
|
||||
child_id: int, request: Request
|
||||
) -> AdminPresenceScheduleReadResponse:
|
||||
"""Return the child's presence schedule, or ``schedule: null``.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: one
|
||||
:func:`nestquest_core.presence_management.get_presence_schedule`
|
||||
call; a stored schedule is serialized exactly as the PUT response
|
||||
is, and a child without one answers ``{"schedule": null}`` (present
|
||||
every day). Errors are mapped by :func:`_raise_child_error`: an
|
||||
unknown child is 404.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
try:
|
||||
schedule = await core_presence_management.get_presence_schedule(
|
||||
state.database, child_id
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_child_error(error)
|
||||
return AdminPresenceScheduleReadResponse(
|
||||
schedule=(
|
||||
None if schedule is None else _presence_schedule_response(schedule)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/presence-overrides",
|
||||
summary="List presence overrides",
|
||||
response_model=AdminPresenceOverrideListResponse,
|
||||
)
|
||||
async def admin_list_presence_overrides(
|
||||
request: Request,
|
||||
child_id: int | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> AdminPresenceOverrideListResponse:
|
||||
"""Return the household's presence overrides, optionally filtered.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: the optional
|
||||
``child_id``, ``start`` and ``end`` query parameters go straight to
|
||||
:func:`nestquest_core.presence_management.list_presence_overrides`
|
||||
— the date policy (strict ``YYYY-MM-DD``, ``end >= start``) and the
|
||||
overlap rule live in the core — and each record is serialized
|
||||
exactly as the POST response is, ordered by start date, child id,
|
||||
id. Errors are mapped by :func:`_raise_child_error`: an unknown
|
||||
``child_id`` is 404, a rejected date or range is 422.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
try:
|
||||
records = await core_presence_management.list_presence_overrides(
|
||||
state.database, child_id=child_id, start=start, end=end
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_child_error(error)
|
||||
return AdminPresenceOverrideListResponse(
|
||||
overrides=[_presence_override_response(record) for record in records]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/uncomplete",
|
||||
summary="Reverse one completion (append a reversal event)",
|
||||
@@ -2084,51 +1729,4 @@ async def admin_run_missed_sweep(
|
||||
return AdminMissedSweepResponse(fired=len(events))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/snapshot",
|
||||
summary="Today's household snapshot",
|
||||
response_model=AdminSnapshotResponse,
|
||||
)
|
||||
async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
|
||||
"""Return today's full household snapshot for the admin Today tab.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: ONE clock read
|
||||
(:func:`_local_now`) goes into
|
||||
:func:`nestquest_core.snapshot.build_snapshot` against the live
|
||||
database, and each child's instances are shaped with
|
||||
``instance_payload(include_missed=True)`` — missed instances are
|
||||
KEPT in the admin payload (D-009) — into
|
||||
:class:`AdminSnapshotResponse`.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
snapshot = await core_snapshot.build_snapshot(
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
)
|
||||
return AdminSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
cycle_day=snapshot.cycle_day,
|
||||
children=[
|
||||
AdminChildSnapshotPayload(
|
||||
child_id=child.child_id,
|
||||
child_name=child.child_name,
|
||||
present=child.present,
|
||||
next_present=child.next_present,
|
||||
due_today=child.due_today,
|
||||
completed_today=child.completed_today,
|
||||
remaining_today=child.remaining_today,
|
||||
completion_pct=child.completion_pct,
|
||||
instances=[
|
||||
AdminInstancePayload.model_validate(instance)
|
||||
for instance in core_snapshot.instance_payload(
|
||||
child.instances, include_missed=True
|
||||
)
|
||||
],
|
||||
)
|
||||
for child in snapshot.children
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -1,78 +1,375 @@
|
||||
"""The NestQuest integration (Feature 18: a thin client of the NestQuest API service).
|
||||
|
||||
The integration owns NO database: the API service (Feature 16) owns the
|
||||
SQLite file, the migrations, and every business rule. Setup builds the
|
||||
entry's ONE shared API client, the coordinator that polls the panel
|
||||
snapshot through it, and the SSE subscription that re-fires the API's
|
||||
transition frames on the HA bus, registers the single
|
||||
``nestquest.complete_quest`` proxy service, registers the zero-config
|
||||
panel dashboard (Feature 20), and forwards the sensor and
|
||||
binary_sensor platforms.
|
||||
"""
|
||||
"""The NestQuest integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import inspect
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
|
||||
from .const import (
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
||||
from .dashboard import async_register_dashboard
|
||||
from .core.settings import NestQuestSettings
|
||||
from .frontend import async_register_frontend
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase, make_database
|
||||
from .materialize import materialize as _materialize_run
|
||||
from .migrations import apply_migrations
|
||||
from .services import async_deregister_services, async_register_services
|
||||
from .sse import NestQuestEventStream
|
||||
from .store import async_get_db_path
|
||||
from .admin_allowlist import seed_setup_admin
|
||||
from .sweep import run_missed_sweep
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
#: SQLite reports "file is not a database" for corruption in every
|
||||
#: sqlite3 version this integration targets; the version-specific
|
||||
#: "encrypted or malformed" spelling (3.41+) maps to the same failure.
|
||||
_CORRUPTION_MARKERS = (
|
||||
"file is not a database",
|
||||
"database disk image is malformed",
|
||||
"encrypted or malformed",
|
||||
"is not a database",
|
||||
)
|
||||
|
||||
#: Primary sqlite error codes that mean "the file is corrupt" (SQLite
|
||||
#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended
|
||||
#: codes carry the primary code in their low byte, so the match is on
|
||||
#: errcode & 0xff.
|
||||
_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB
|
||||
|
||||
|
||||
def _is_corruption_error(error: BaseException) -> bool:
|
||||
"""Return True when ``error`` indicates a corrupt database file.
|
||||
|
||||
Classification order: sqlite_errorcode (Python 3.11+) is primary
|
||||
and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched
|
||||
on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.)
|
||||
classify correctly too. Message matching is the fallback for
|
||||
older Pythons: the corruption spellings are the documented English
|
||||
texts; a non-English locale could evade them, but the code check
|
||||
already covers every locale on supported Pythons.
|
||||
"""
|
||||
if not isinstance(error, sqlite3.DatabaseError):
|
||||
return False
|
||||
errorcode = getattr(error, "sqlite_errorcode", None)
|
||||
if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES:
|
||||
return True
|
||||
message = str(error).lower()
|
||||
return any(marker in message for marker in _CORRUPTION_MARKERS)
|
||||
|
||||
|
||||
async def _preflight_existing_file(
|
||||
hass: HomeAssistant, db_path: Path
|
||||
) -> None:
|
||||
"""Detect corruption in an EXISTING file before it is opened read-write.
|
||||
|
||||
Opening read-write persists WAL mode — for a rollback-journal file
|
||||
that rewrites the header byte 18/19 and can create -wal/-shm
|
||||
sidecars — which would modify a corrupt file before detection and
|
||||
break the leave-untouched guarantee. This preflight therefore
|
||||
validates the WHOLE file with a READ-ONLY connection (mode=ro +
|
||||
immutable=1 URI, PRAGMA integrity_check, run on the executor like
|
||||
every other DB call): corruption anywhere fails there, before
|
||||
anything is written. ``immutable=1`` is SQLite's documented
|
||||
guarantee that the connection performs NO writes and creates NO
|
||||
sidecars — even a WAL-header file gets its -wal/-shm reads served
|
||||
without creating the shared-memory file. Missing/empty files skip
|
||||
the preflight (they are created fresh by the real open). All
|
||||
filesystem stat/URI work runs inside the executor job with the
|
||||
probe so the event loop never touches disk.
|
||||
"""
|
||||
def _probe() -> None:
|
||||
# The file-state check and URI construction live inside the
|
||||
# executor job: exists/stat/resolve are disk I/O.
|
||||
if not db_path.exists():
|
||||
return
|
||||
# A zero-byte file is neither missing nor a valid database: it
|
||||
# is a corrupt (truncated-to-nothing) existing file, likely an
|
||||
# interrupted first write. The leave-untouched contract applies
|
||||
# to it too: refuse rather than silently initialize over it.
|
||||
if db_path.stat().st_size == 0:
|
||||
raise sqlite3.DatabaseError(
|
||||
"existing database file is empty (zero bytes): file is "
|
||||
"not a database"
|
||||
)
|
||||
# Properly escape the path: '#'/'?'/'%' in a directory or file
|
||||
# name would otherwise be parsed as URI fragments/parameters/
|
||||
# escapes. immutable=1: SQLite guarantees no writes and no
|
||||
# sidecar creation from this connection.
|
||||
uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
verdict = row[0] if row else "missing"
|
||||
if verdict != "ok":
|
||||
# A non-ok integrity verdict IS corruption: raise with the
|
||||
# verdict text (which matches the corruption markers) and
|
||||
# the NOTADB code so classification is code-based.
|
||||
raise sqlite3.DatabaseError(
|
||||
f"integrity_check verdict {verdict!r} on read-only "
|
||||
f"probe: file is not a database"
|
||||
)
|
||||
|
||||
try:
|
||||
# Every DB-touching call goes through the executor (the mock
|
||||
# harness's async_add_executor_job is awaitable too).
|
||||
await hass.async_add_executor_job(_probe)
|
||||
except sqlite3.DatabaseError as err:
|
||||
# Non-ok integrity verdicts are corruption, full stop: there is
|
||||
# no benign reading of a failed whole-file integrity check, so
|
||||
# ANY DatabaseError out of the probe that carries a corruption
|
||||
# marker or code propagates as corruption. Only exotic probe
|
||||
# failures that classify as non-corruption (e.g. a transient
|
||||
# 'database is locked' on the read) are downgraded to a debug
|
||||
# log, letting the real open path surface them.
|
||||
if _is_corruption_error(err):
|
||||
raise
|
||||
raise sqlite3.DatabaseError(
|
||||
f"read-only corruption probe of {db_path} failed: {err}"
|
||||
) from err
|
||||
|
||||
async def _async_open_database(
|
||||
hass: HomeAssistant, db_path: Path
|
||||
) -> NestQuestDatabase:
|
||||
"""Open the NestQuest database, mapping failure modes for setup.
|
||||
|
||||
Three startup cases (Feature 02 done-condition):
|
||||
|
||||
- file missing: SQLite creates it fresh on open — no special path,
|
||||
migrations stamp version 1 on the next step.
|
||||
- file present and valid: opens normally.
|
||||
- file present but corrupt: log an ERROR with the path, close
|
||||
anything opened, and raise ConfigEntryNotReady so HA retries —
|
||||
the file is left byte-for-byte untouched so the owner can
|
||||
restore a backup. No code path here deletes or overwrites an
|
||||
existing database file.
|
||||
|
||||
Corruption can surface at EITHER stage: a truncated header fails
|
||||
in the read-only preflight (before the file is ever opened
|
||||
read-write), while damaged later pages fail during migrations —
|
||||
the preflight guarantees no WAL sidecar or header write happens
|
||||
on a corrupt file before it is detected. Both are classified the
|
||||
same way.
|
||||
"""
|
||||
try:
|
||||
await _preflight_existing_file(hass, db_path)
|
||||
database = await make_database(hass).open(db_path)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except sqlite3.DatabaseError as err:
|
||||
if _is_corruption_error(err):
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s is corrupt: %s. Refusing to "
|
||||
"start with a corrupt database; the file was left "
|
||||
"untouched so a backup can be restored. Home Assistant "
|
||||
"will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database corrupt: {db_path}"
|
||||
) from err
|
||||
# open() failed for a non-corruption reason (locked file,
|
||||
# permission problem): retryable, but nothing was opened.
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s failed to open: %s. Home "
|
||||
"Assistant will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database unavailable: {db_path}"
|
||||
) from err
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
except (sqlite3.DatabaseError, RuntimeError) as err:
|
||||
await database.close()
|
||||
if _is_corruption_error(err):
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s is corrupt: %s. Refusing to "
|
||||
"start with a corrupt database; the file was left "
|
||||
"untouched so a backup can be restored. Home Assistant "
|
||||
"will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database corrupt: {db_path}"
|
||||
) from err
|
||||
# A non-corruption failure (e.g. a locked file, a future schema
|
||||
# version) is also a retryable setup problem, but without the
|
||||
# corruption framing.
|
||||
_LOGGER.error(
|
||||
"NestQuest database at %s failed to open: %s. Home "
|
||||
"Assistant will retry setup.",
|
||||
db_path,
|
||||
err,
|
||||
)
|
||||
raise ConfigEntryNotReady(
|
||||
f"NestQuest database unavailable: {db_path}"
|
||||
) from err
|
||||
except asyncio.CancelledError:
|
||||
await database.close()
|
||||
raise
|
||||
return database
|
||||
|
||||
|
||||
@dataclass
|
||||
class NestQuestRuntimeData:
|
||||
"""Runtime data stored on a NestQuest config entry."""
|
||||
|
||||
entry_id: str
|
||||
settings: NestQuestSettings
|
||||
database: NestQuestDatabase
|
||||
remove_update_listener: Callable[[], Any]
|
||||
remove_time_change_listener: Callable[[], Any]
|
||||
coordinator: Any = None
|
||||
event_stream: NestQuestEventStream | None = None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def _run_horizon_materialization(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
) -> None:
|
||||
"""Materialize the rolling horizon ``[today, today + 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.
|
||||
|
||||
This is the ONE generation path: the startup backfill, the daily rollover
|
||||
listener and the ``regenerate`` service all funnel through it, so there is
|
||||
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
|
||||
silent default; every caller already passes the entry's validated
|
||||
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
|
||||
window itself comes from :meth:`settings.horizon_window` so the helper is
|
||||
real and shared (not recomputed inline here).
|
||||
"""
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
start, end = settings.horizon_window(today)
|
||||
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
|
||||
|
||||
|
||||
def _register_day_rollover_listener(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
) -> Callable[[], Any]:
|
||||
"""Register the daily rollover listener and return its remover.
|
||||
|
||||
Fires once per day at the configured local ``day_rollover_time``:
|
||||
first re-materializes the rolling horizon
|
||||
``[today, today + horizon_days]`` through the shared
|
||||
:func:`_run_horizon_materialization` path (the entry's configured,
|
||||
validated ``horizon_days`` carried on ``settings``), then runs the
|
||||
Feature 11 missed sweep — announcing yesterday's still-open quests
|
||||
as missed AFTER today's instances exist, watermark-guarded so a
|
||||
re-fire of the listener the same night announces nothing twice.
|
||||
"""
|
||||
hour, minute = settings.day_rollover_hour_minute
|
||||
|
||||
async def _run_materialization(_now: datetime.datetime) -> None:
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
await run_missed_sweep(hass, database)
|
||||
|
||||
return async_track_time_change(
|
||||
hass, _run_materialization, hour=hour, minute=minute
|
||||
)
|
||||
|
||||
|
||||
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||
"""Return a live runtime record with a coordinator, or None.
|
||||
"""Return a live runtime record with a connected database, or None.
|
||||
|
||||
The domain-global service (the panel completion proxy) has no
|
||||
database of its own; it resolves whichever config entry's runtime
|
||||
data is currently live at call time, so it cannot hold a stale
|
||||
handle to an unloaded entry's coordinator.
|
||||
The domain-global services (e.g. ``regenerate``) have no database of
|
||||
their own; they resolve whichever config entry's runtime data is
|
||||
currently live at call time, so none of them can hold a stale handle
|
||||
to a closed connection after another entry unloads. The record also
|
||||
carries that entry's validated ``settings`` (e.g. the configured
|
||||
horizon) so service handlers read horizon days off it rather than
|
||||
re-reading HA config.
|
||||
"""
|
||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||
if getattr(runtime_data, "coordinator", None) is not None:
|
||||
database = getattr(runtime_data, "database", None)
|
||||
if database is not None and database.connected:
|
||||
return runtime_data
|
||||
return None
|
||||
|
||||
|
||||
def _register_services(hass: HomeAssistant) -> None:
|
||||
"""Register the domain-global NestQuest service once.
|
||||
def _make_regenerate_handler(hass: HomeAssistant):
|
||||
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
||||
|
||||
The service is DOMAIN-global — registered exactly once (guarded by
|
||||
``has_service``) and NOT bound to any config entry — so the panel
|
||||
completion proxy resolves the currently-live runtime at call time
|
||||
async def _regenerate(_call: Any) -> None:
|
||||
runtime_data = _find_live_runtime_data(hass)
|
||||
if runtime_data is None:
|
||||
_LOGGER.warning(
|
||||
"NestQuest regenerate requested but no config entry has a "
|
||||
"live database; ignoring"
|
||||
)
|
||||
return
|
||||
await _run_horizon_materialization(
|
||||
hass, runtime_data.database, runtime_data.settings
|
||||
)
|
||||
|
||||
return _regenerate
|
||||
|
||||
|
||||
def _register_services(hass: HomeAssistant) -> None:
|
||||
"""Register domain-global NestQuest services once.
|
||||
|
||||
Services are DOMAIN-global — registered exactly once (guarded by
|
||||
``has_service`` on regenerate) and NOT bound to any config entry —
|
||||
so each handler resolves the currently-live database at call time
|
||||
and a name collision or duplicate setup is impossible. Removal is
|
||||
handled by the unload path only once the LAST entry is removed
|
||||
(see :func:`_deregister_services`).
|
||||
"""
|
||||
async_register_services(hass, find_runtime=_find_live_runtime_data)
|
||||
async_register_services(
|
||||
hass,
|
||||
find_runtime=_find_live_runtime_data,
|
||||
regenerate_handler=_make_regenerate_handler(hass),
|
||||
)
|
||||
|
||||
|
||||
def _deregister_services(hass: HomeAssistant) -> None:
|
||||
@@ -89,14 +386,48 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up NestQuest from a config entry."""
|
||||
await async_register_frontend(hass)
|
||||
# The zero-config panel dashboard (Feature 20): register the
|
||||
# storage-mode strategy dashboard if no dashboard under the stable
|
||||
# url_path exists yet. Unavailability of the Lovelace dashboards
|
||||
# collection (YAML mode, lovelace not loaded) logs a warning and
|
||||
# NEVER fails setup; an existing dashboard — ours or the user's —
|
||||
# is left untouched, so a reload cannot duplicate it.
|
||||
await async_register_dashboard(hass)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
existing = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if existing is not None:
|
||||
db = getattr(existing, "database", None)
|
||||
if db is None or not db.connected:
|
||||
# Reopen through the SAME safety path as first setup: a
|
||||
# record without a live connection (a previous setup's
|
||||
# close-on-failure, or a hand-replaced file) must get the
|
||||
# missing/valid/corrupt classification too, never a raw
|
||||
# sqlite3 error and never skipped migrations — and the
|
||||
# same allowlist seeding, so a reopened fresh file is
|
||||
# never ownerless.
|
||||
db = await _async_open_database(
|
||||
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
|
||||
entry.runtime_data = existing
|
||||
return True
|
||||
|
||||
async def _async_update_listener(
|
||||
listener_hass: HomeAssistant, listener_entry: ConfigEntry
|
||||
@@ -104,94 +435,109 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Reload the entry when its options change."""
|
||||
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
||||
|
||||
database = await _async_open_database(hass, await async_get_db_path(hass))
|
||||
remove_update_listener: Callable[[], Any] | None = None
|
||||
remove_time_change_listener: Callable[[], Any] | None = None
|
||||
coordinator: Any = None
|
||||
event_stream: NestQuestEventStream | None = None
|
||||
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,
|
||||
)
|
||||
# Build the explicit settings object from entry.options ONCE: the
|
||||
# horizon materialization and the daily-rollover listener both
|
||||
# consume it, so core never reads HA config itself. A malformed
|
||||
# stored option falls back PER FIELD (via from_options_resilient)
|
||||
# so a single bad sibling option cannot reset the user's
|
||||
# horizon_days or day_rollover_time — the two fields core
|
||||
# consumes — to their defaults.
|
||||
settings = NestQuestSettings.from_options_resilient(entry.options)
|
||||
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
||||
remove_time_change_listener = _register_day_rollover_listener(
|
||||
hass, database, settings
|
||||
)
|
||||
# Backfill any days the daily listener missed while the integration
|
||||
# was off (or freshly installed): run the one idempotent walk over
|
||||
# [today, today + horizon_days] once at setup, using the entry's
|
||||
# configured horizon. This runs through the SAME path as the daily
|
||||
# listener and regenerate service.
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
# The missed sweep runs at startup too (Feature 11): if HA was
|
||||
# down at the rollover time, the previous night's still-open
|
||||
# quests are announced as missed now instead of waiting for
|
||||
# the next midnight. Watermark-guarded, so this is a no-op
|
||||
# when the night's sweep already ran.
|
||||
await run_missed_sweep(hass, database)
|
||||
_register_services(hass)
|
||||
# ONE API client for the entry, shared by the coordinator's
|
||||
# snapshot polling, the SSE subscription, and the completion
|
||||
# proxy (the client is stateless — one session, one connection
|
||||
# pool). An entry with no panel token yet gets the
|
||||
# typed-error stub (unavailable entities, never a setup crash).
|
||||
api_client = coordinator_client_from_entry(hass, entry)
|
||||
# The shared coordinator: one refresh cycle every entity reads
|
||||
# from (CONF_UPDATE_INTERVAL seconds, default five minutes).
|
||||
# It polls the API service's panel snapshot; a failed poll
|
||||
# keeps the entities rendering the last-good snapshot within
|
||||
# CONF_SNAPSHOT_STALENESS, unavailable past it.
|
||||
# The shared Feature 10 coordinator: one refresh cycle every
|
||||
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
|
||||
# five minutes). Created AFTER services so a failure below
|
||||
# unwinds them through the except path's listener handling.
|
||||
coordinator = NestQuestCoordinator(
|
||||
hass,
|
||||
entry_id=entry.entry_id,
|
||||
api_client=api_client,
|
||||
database=database,
|
||||
settings=settings,
|
||||
update_interval_seconds=entry.options.get(
|
||||
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
||||
),
|
||||
staleness_seconds=entry.options.get(
|
||||
CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS
|
||||
),
|
||||
)
|
||||
# The panel event subscription (the SSE task): a background
|
||||
# task consumes the API service's transition-event stream and
|
||||
# re-fires the four documented HA bus events. It reuses the
|
||||
# coordinator's client, so it only starts when that client
|
||||
# actually offers a stream — an unconfigured-token stub (or a
|
||||
# test snapshot client without ``stream_events``) leaves it
|
||||
# unstarted, and the typed-error path already covers
|
||||
# availability for those entries.
|
||||
if callable(getattr(api_client, "stream_events", None)):
|
||||
event_stream = NestQuestEventStream(hass, api_client)
|
||||
event_stream.start()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
settings=settings,
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
remove_time_change_listener=remove_time_change_listener,
|
||||
coordinator=coordinator,
|
||||
event_stream=event_stream,
|
||||
)
|
||||
# The runtime record is registered BEFORE the first refresh:
|
||||
# the service proxy resolves the entry's runtime state through
|
||||
# hass.data, and it must find the live coordinator there —
|
||||
# never a half-set-up entry. The platforms forward below reads
|
||||
# the coordinator off the entry (the HA runtime-data contract);
|
||||
# a forward failure below unwinds it through the except path.
|
||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||
# runtime_data is assigned BEFORE the platforms forward so each
|
||||
# platform's async_setup_entry reads its coordinator off the
|
||||
# entry (the HA runtime-data contract); a forward failure below
|
||||
# unwinds it through the except path.
|
||||
entry.runtime_data = runtime_data
|
||||
# The first refresh polls the API snapshot; an API failure
|
||||
# (unconfigured token, API service down) does NOT crash setup:
|
||||
# the coordinator reports last_update_success=False, entities
|
||||
# come up unavailable, and HA's standard retry cycle applies
|
||||
# once the API is reachable. HA's real
|
||||
# ``async_config_entry_first_refresh`` wraps ANY failed update
|
||||
# pass in ConfigEntryNotReady — the original error survives
|
||||
# only as ``__cause__``, never as the raised exception — so the
|
||||
# catch keys on ConfigEntryNotReady, NOT on the typed API
|
||||
# error. Everything else (a platform-forward failure,
|
||||
# cancellation) still unwinds through the except block below.
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except ConfigEntryNotReady:
|
||||
pass
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
except BaseException:
|
||||
# A failure anywhere after the listener was registered must not
|
||||
# leak it (or the SSE task): unwind EVERYTHING acquired before
|
||||
# the failure, keeping the original error as the one raised.
|
||||
# Removal is best-effort — a failing step must not mask the
|
||||
# setup error that triggered it.
|
||||
if event_stream is not None:
|
||||
# A failure anywhere after the database is open must not leak the
|
||||
# listeners already registered against that (now-closed) database:
|
||||
# unwind EVERY remover we acquired before the failure, keeping the
|
||||
# original error as the one raised. Removal is best-effort — a
|
||||
# failing remover must not mask the setup error that triggered it.
|
||||
for remove_listener in (
|
||||
remove_time_change_listener,
|
||||
remove_update_listener,
|
||||
):
|
||||
if remove_listener is None:
|
||||
continue
|
||||
try:
|
||||
await event_stream.stop()
|
||||
except BaseException:
|
||||
pass
|
||||
if remove_update_listener is not None:
|
||||
try:
|
||||
remove_update_listener()
|
||||
result = remove_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException:
|
||||
pass
|
||||
# A coordinator created before the failure holds entity
|
||||
# listeners pointed at the never-registered runtime — shut it
|
||||
# down so no scheduled refresh outlives the failed setup.
|
||||
# listeners pointed at the database being closed below —
|
||||
# shut it down FIRST so no scheduled refresh can ever run
|
||||
# against a closed connection.
|
||||
if coordinator is not None:
|
||||
try:
|
||||
await coordinator.async_shutdown()
|
||||
@@ -199,20 +545,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
pass
|
||||
# A platform-forward failure reached this unwind AFTER
|
||||
# runtime_data was assigned: clear it so no later unload path
|
||||
# re-invokes the already-consumed update-listener remover
|
||||
# through the stale record.
|
||||
# closes the already-closed database through the stale record.
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
# The runtime record was ALSO registered in hass.data before
|
||||
# the first refresh: remove it here on ANY failure, or a later
|
||||
# unload picks up the stale, already-torn-down record
|
||||
# (hass.data wins over entry.runtime_data there) and
|
||||
# re-invokes the already-consumed update-listener remover.
|
||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
await database.close()
|
||||
raise
|
||||
# The runtime record was already registered before the first
|
||||
# refresh; re-assert the (unchanged) record so the return path
|
||||
# stays readable as one registration site.
|
||||
assert remove_update_listener is not None
|
||||
assert remove_time_change_listener is not None
|
||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||
return True
|
||||
|
||||
@@ -227,50 +566,58 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if runtime_data is None:
|
||||
runtime_data = getattr(entry, "runtime_data", None)
|
||||
# Platforms unload FIRST (mirroring HA conventions): entity
|
||||
# listeners must not outlive the coordinator the teardown below
|
||||
# shuts down.
|
||||
# listeners must not outlive the database and coordinator the
|
||||
# teardown below closes.
|
||||
platforms_unloaded = await hass.config_entries.async_unload_platforms(
|
||||
entry, PLATFORMS
|
||||
)
|
||||
if platforms_unloaded is False and runtime_data is not None:
|
||||
# Home Assistant KEPT one or more platforms loaded — their
|
||||
# entities still live and must keep their coordinator. Restore
|
||||
# entities still live and must keep their database. Restore
|
||||
# the runtime record popped above and report failure so HA
|
||||
# retries the unload later instead of leaving live entities
|
||||
# backed by a torn-down coordinator.
|
||||
# backed by a closed database.
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
|
||||
return False
|
||||
unload_error: BaseException | None = None
|
||||
if runtime_data is not None:
|
||||
# The SSE subscription is stopped FIRST: its task must be gone
|
||||
# (no frame can fire) before anything else is torn down.
|
||||
event_stream = getattr(runtime_data, "event_stream", None)
|
||||
if event_stream is not None:
|
||||
stop_stream = getattr(event_stream, "stop", None)
|
||||
if stop_stream is not None:
|
||||
await stop_stream()
|
||||
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
|
||||
)
|
||||
try:
|
||||
if remove_update_listener is not None:
|
||||
result = remove_update_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException as err:
|
||||
# A failing remover must not skip the coordinator shutdown
|
||||
# below; the first error is retained and re-raised only
|
||||
# after the teardown ran.
|
||||
if unload_error is None:
|
||||
unload_error = err
|
||||
for remove_listener in (
|
||||
remove_update_listener,
|
||||
remove_time_change_listener,
|
||||
):
|
||||
if remove_listener is None:
|
||||
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:
|
||||
# The coordinator's entity listeners must stop here: a
|
||||
# scheduled refresh must never outlive the unload.
|
||||
# The coordinator's entity listeners must stop BEFORE the
|
||||
# database closes: a scheduled refresh starting during the
|
||||
# close await would touch a cleared connection. (The
|
||||
# platform unload above already removed the entities; this
|
||||
# clears the coordinator's own remaining listeners.)
|
||||
coordinator = getattr(runtime_data, "coordinator", None)
|
||||
if coordinator is not None:
|
||||
shutdown = getattr(coordinator, "async_shutdown", None)
|
||||
if shutdown is not None:
|
||||
await shutdown()
|
||||
database = getattr(runtime_data, "database", None)
|
||||
if database is not None:
|
||||
await database.close()
|
||||
# Domain-global services are torn down only once the FINAL entry
|
||||
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
||||
# entry is still loaded.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
|
||||
|
||||
The admin allowlist business layer lives in
|
||||
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .admin_allowlist import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.admin_allowlist import * # noqa: F401,F403
|
||||
@@ -1,414 +0,0 @@
|
||||
"""Panel-plane API client for the NestQuest integration (Feature 18).
|
||||
|
||||
The integration talks to the NestQuest API service (Feature 16) over
|
||||
HTTP instead of reading the database: this module is the ONE place that
|
||||
speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
|
||||
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
|
||||
(completion as the tapped child profile) — authenticated with the
|
||||
static panel service token as a Bearer credential on every request.
|
||||
|
||||
Transport-injected by construction: the constructor takes the base URL,
|
||||
the panel token, and the HTTP session to use. The session speaks the
|
||||
aiohttp ``ClientSession`` surface (``request(method, url, headers=...,
|
||||
json=...)`` returning an async context manager whose response carries
|
||||
``status`` and ``await text()``), so tests stub the transport with NO
|
||||
network and NO aiohttp installed; production builds the client through
|
||||
:func:`client_from_entry`, which hands in Home Assistant's shared
|
||||
aiohttp session (``homeassistant.helpers.aiohttp_client
|
||||
.async_get_clientsession``). The module itself imports NEITHER
|
||||
homeassistant NOR aiohttp at runtime (aiohttp appears only behind
|
||||
``TYPE_CHECKING`` for annotations), so the client is usable anywhere —
|
||||
the HA coupling lives in the one production factory.
|
||||
|
||||
Every failure is typed: a transport failure (connection refused, DNS
|
||||
failure, timeout — the exchange never produced a response) and every
|
||||
non-2xx HTTP response raise :class:`NestQuestApiError`. The error
|
||||
carries the HTTP status where there is one (so 401 and 404 are
|
||||
distinguishable at the call site) and ``None`` when there is not; the
|
||||
original transport error is chained as ``__cause__`` — nothing is
|
||||
swallowed. There are NO retries and the client logs nothing: refresh
|
||||
cadence is the coordinator's concern (the next task), and the panel
|
||||
token is a secret that must never reach a log line or an error
|
||||
message — the error texts name the method, path, and status only.
|
||||
|
||||
The panel plane's SSE event stream (``GET /api/v1/panel/events``) is
|
||||
exposed by :meth:`NestQuestApiClient.stream_events` — an async
|
||||
generator yielding ``(event_type, payload)`` per Server-Sent Events
|
||||
frame (``event: <type>`` / ``data: <json>`` lines separated by a blank
|
||||
line). Opening the stream gets the same one-timeout, typed-error
|
||||
contract as every other call; reading it has NO body-wide timeout (a
|
||||
healthy stream is long-lived — the subscription manager owns
|
||||
reconnection and backoff).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .const import (
|
||||
CONF_API_BASE_URL,
|
||||
CONF_PANEL_TOKEN,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
|
||||
__all__ = [
|
||||
"COMPLETE_INSTANCE_PATH",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"EVENTS_PATH",
|
||||
"NestQuestApiError",
|
||||
"NestQuestApiClient",
|
||||
"SNAPSHOT_PATH",
|
||||
"client_from_entry",
|
||||
"is_valid_base_url",
|
||||
"resolve_api_config",
|
||||
]
|
||||
|
||||
#: The panel plane's snapshot route (Feature 16).
|
||||
SNAPSHOT_PATH = "/api/v1/panel/snapshot"
|
||||
|
||||
#: The panel plane's SSE event-stream route (Feature 16): each
|
||||
#: transition arrives as an ``event: <type>`` / ``data: <json>`` frame.
|
||||
EVENTS_PATH = "/api/v1/panel/events"
|
||||
|
||||
#: The panel plane's completion route template (Feature 16); the
|
||||
#: ``{instance_id}`` placeholder is filled per call.
|
||||
COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete"
|
||||
|
||||
#: Request timeout, in seconds. One attempt, one timeout: a hung API
|
||||
#: call surfaces as a typed error for the coordinator to report, never
|
||||
#: a retry loop.
|
||||
DEFAULT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
class NestQuestApiError(Exception):
|
||||
"""A failed NestQuest API exchange, raised for every failure mode.
|
||||
|
||||
``status`` carries the HTTP status when a response arrived (a
|
||||
non-2xx response — 401 for a bad token, 404 for an unknown
|
||||
instance) and is ``None`` when the exchange failed at the transport
|
||||
level (connection refused, DNS failure, timeout) — so callers
|
||||
distinguish the two with ``is_transport_error``. The original
|
||||
transport error, where there is one, is chained as ``__cause__``.
|
||||
Messages carry the method, path, and status — never the request
|
||||
headers, so the panel token can never leak through an error.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, status: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
@property
|
||||
def is_transport_error(self) -> bool:
|
||||
"""True when the exchange failed before any response arrived."""
|
||||
return self.status is None
|
||||
|
||||
|
||||
def is_valid_base_url(value: object) -> bool:
|
||||
"""Return True when ``value`` is a usable API base URL.
|
||||
|
||||
A full ``http://`` or ``https://`` URL with a host — the exact
|
||||
shape the options flow validates (form ergonomics) and the client
|
||||
constructor enforces (fail fast, never build requests against a
|
||||
scheme-less or host-less string).
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
parsed = urlparse(candidate)
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
def resolve_api_config(entry: Any) -> tuple[str, str]:
|
||||
"""Resolve ``(base_url, panel_token)`` off a config entry.
|
||||
|
||||
Precedence mirrors the options flow's stored-values resolution:
|
||||
``entry.options`` first (the live options-flow copy), then
|
||||
``entry.data`` (the config-flow-era copy), then the const defaults.
|
||||
The token is a secret: callers must never log the returned pair.
|
||||
"""
|
||||
options = getattr(entry, "options", None) or {}
|
||||
data = getattr(entry, "data", None) or {}
|
||||
|
||||
def _pick(key: str, default: str) -> str:
|
||||
if key in options:
|
||||
return options[key]
|
||||
if key in data:
|
||||
return data[key]
|
||||
return default
|
||||
|
||||
return (
|
||||
_pick(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
|
||||
_pick(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
||||
)
|
||||
|
||||
|
||||
def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
|
||||
"""Build the production client for a config entry.
|
||||
|
||||
Reads the configured base URL and panel token off the entry
|
||||
(:func:`resolve_api_config`) and hands the client Home Assistant's
|
||||
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
|
||||
.async_get_clientsession``) so panel calls ride HA's one connection
|
||||
pool. The helper import is deferred into this function on purpose:
|
||||
the test harness is mock-only and the module must import (and the
|
||||
client must be stub-transport testable) without homeassistant or
|
||||
aiohttp installed.
|
||||
"""
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
base_url, panel_token = resolve_api_config(entry)
|
||||
return NestQuestApiClient(
|
||||
base_url, panel_token, async_get_clientsession(hass)
|
||||
)
|
||||
|
||||
|
||||
class NestQuestApiClient:
|
||||
"""Async client for the NestQuest API service's panel plane.
|
||||
|
||||
ONE instance per config entry (the coordinator task wires it into
|
||||
the refresh cycle). The client is deliberately thin: it adds the
|
||||
Bearer credential and the base URL, enforces one timeout per
|
||||
request, and turns every failure into :class:`NestQuestApiError` —
|
||||
no business logic, no retries, no caching.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
panel_token: str,
|
||||
session: aiohttp.ClientSession,
|
||||
*,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
"""Store the connection settings; the session is injected.
|
||||
|
||||
``session`` is an aiohttp ``ClientSession`` in production and a
|
||||
stub transport in tests. ``base_url`` must be a full http(s)
|
||||
URL and ``panel_token`` must be non-empty — a client built
|
||||
without either can only fail later at request time with a
|
||||
less-explicable error, so construction fails fast instead.
|
||||
"""
|
||||
if not is_valid_base_url(base_url):
|
||||
raise ValueError(
|
||||
f"base_url must be a full http(s) URL, got {base_url!r}"
|
||||
)
|
||||
if not isinstance(panel_token, str) or not panel_token.strip():
|
||||
raise ValueError("panel_token must be a non-empty string")
|
||||
if (
|
||||
not isinstance(timeout_seconds, (int, float))
|
||||
or isinstance(timeout_seconds, bool)
|
||||
or timeout_seconds <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"timeout_seconds must be a positive number, got {timeout_seconds!r}"
|
||||
)
|
||||
self._base_url = base_url.strip().rstrip("/")
|
||||
self._panel_token = panel_token
|
||||
self._session = session
|
||||
self._timeout_seconds = float(timeout_seconds)
|
||||
|
||||
async def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Fetch and parse today's household snapshot.
|
||||
|
||||
Returns the JSON the snapshot route returns (``today_iso``,
|
||||
``cycle_day``, and the per-child list — see
|
||||
``api/routes_panel.py`` for the documented shape). Raises
|
||||
:class:`NestQuestApiError` on transport failure, timeout, or a
|
||||
non-2xx response.
|
||||
"""
|
||||
return await self._request_json("GET", SNAPSHOT_PATH)
|
||||
|
||||
async def complete_instance(
|
||||
self, instance_id: int, actor_child_id: int
|
||||
) -> dict[str, Any]:
|
||||
"""Complete one quest instance as the tapped child profile.
|
||||
|
||||
POSTs ``{"actor_child_id": actor_child_id}`` to the complete
|
||||
route and returns the parsed response body (the route answers
|
||||
``{"status": ...}`` both for a first completion and for an
|
||||
idempotent repeat, so a retried panel tap succeeds). Raises
|
||||
:class:`NestQuestApiError` on transport failure, timeout, or a
|
||||
non-2xx response — 401 for a wrong token, 404 for an unknown
|
||||
instance.
|
||||
"""
|
||||
path = COMPLETE_INSTANCE_PATH.format(instance_id=instance_id)
|
||||
return await self._request_json(
|
||||
"POST", path, json_body={"actor_child_id": actor_child_id}
|
||||
)
|
||||
|
||||
async def stream_events(
|
||||
self,
|
||||
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
||||
"""Yield ``(event_type, payload)`` for every panel SSE frame.
|
||||
|
||||
Opens ``GET /api/v1/panel/events`` with the Bearer credential
|
||||
and parses the Server-Sent Events framing the API service
|
||||
emits: ``event: <type>`` and ``data: <json>`` lines closed by a
|
||||
blank line (one frame per transition event; a frame's ``data``
|
||||
may span several ``data:`` lines, joined with newlines — the
|
||||
standard SSE rule). Line splits across chunk boundaries are
|
||||
buffered, so frames are parsed correctly whatever chunking the
|
||||
transport produces.
|
||||
|
||||
Opening the stream carries the SAME one-timeout, typed-error
|
||||
contract as every other call: a non-2xx response raises
|
||||
:class:`NestQuestApiError` with its status and a transport
|
||||
failure (connection refused, DNS, the open timing out) raises
|
||||
one with ``status=None`` and the original error chained. The
|
||||
reading loop itself has NO timeout — a healthy stream is
|
||||
long-lived — but any failure raised from the transport
|
||||
mid-stream raises typed as well, so the consumer can reconnect.
|
||||
A frame whose ``data`` is not valid JSON is a protocol failure
|
||||
and raises typed, carrying the response status it arrived
|
||||
with. Cancellation is never converted: closing the generator
|
||||
(``aclose``, i.e. the consumer's task being cancelled) stops
|
||||
cleanly and closes the underlying response in every path.
|
||||
"""
|
||||
url = f"{self._base_url}{EVENTS_PATH}"
|
||||
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||
# Opening the response gets ONE request timeout, exactly like
|
||||
# ``_request_json``; the read loop below runs WITHOUT one — a
|
||||
# body-wide timeout would kill a healthy long-lived stream.
|
||||
# The response context manager is entered manually (not via
|
||||
# ``async with``) because the block must span the yields below;
|
||||
# the ``finally`` closes it on EVERY exit path.
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout_seconds):
|
||||
stream_cm = self._session.request(
|
||||
"GET", url, headers=headers, json=None
|
||||
)
|
||||
response = await stream_cm.__aenter__()
|
||||
except asyncio.CancelledError:
|
||||
# Cancellation is never converted (see ``_request_json``).
|
||||
raise
|
||||
except NestQuestApiError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
|
||||
status=None,
|
||||
) from err
|
||||
|
||||
status = response.status
|
||||
if not 200 <= status < 300:
|
||||
await self._close_stream(stream_cm)
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} failed with HTTP {status}",
|
||||
status=status,
|
||||
)
|
||||
|
||||
try:
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
buffer = ""
|
||||
async for chunk in response:
|
||||
if isinstance(chunk, (bytes, bytearray)):
|
||||
chunk = chunk.decode("utf-8")
|
||||
buffer += chunk
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
if line.startswith("event:"):
|
||||
event_type = line[len("event:"):].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[len("data:"):].strip())
|
||||
elif not line and event_type is not None:
|
||||
# A blank line ends the frame. A trailing
|
||||
# incomplete frame (stream ends without its
|
||||
# closing blank line) is discarded, per the
|
||||
# SSE spec's treatment of incomplete events.
|
||||
try:
|
||||
payload = json.loads("\n".join(data_lines))
|
||||
except ValueError as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} "
|
||||
f"delivered a malformed SSE frame: {err}",
|
||||
status=status,
|
||||
) from err
|
||||
yield event_type, payload
|
||||
event_type = None
|
||||
data_lines = []
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except NestQuestApiError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API GET {EVENTS_PATH} transport failure: {err}",
|
||||
status=None,
|
||||
) from err
|
||||
finally:
|
||||
await self._close_stream(stream_cm)
|
||||
|
||||
@staticmethod
|
||||
async def _close_stream(stream_cm: Any) -> None:
|
||||
"""Close an opened streaming response context manager.
|
||||
|
||||
Works for aiohttp's response context manager (whose
|
||||
``__aexit__`` is a coroutine) and for test stubs that return
|
||||
either a coroutine or a plain value.
|
||||
"""
|
||||
result = stream_cm.__aexit__(None, None, None)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Run ONE request and return the parsed JSON body.
|
||||
|
||||
The Bearer token rides the Authorization header only. A
|
||||
non-2xx response raises :class:`NestQuestApiError` with its
|
||||
status; anything the transport raises (connection, timeout) is
|
||||
wrapped as a transport-level :class:`NestQuestApiError` with
|
||||
the original error chained — never swallowed, never retried.
|
||||
A 2xx body that is not valid JSON is a protocol failure and
|
||||
raises typed too, carrying the status it arrived with.
|
||||
"""
|
||||
url = f"{self._base_url}{path}"
|
||||
headers = {"Authorization": f"Bearer {self._panel_token}"}
|
||||
try:
|
||||
async with asyncio.timeout(self._timeout_seconds):
|
||||
async with self._session.request(
|
||||
method, url, headers=headers, json=json_body
|
||||
) as response:
|
||||
status = response.status
|
||||
if not 200 <= status < 300:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API {method} {path} failed with "
|
||||
f"HTTP {status}",
|
||||
status=status,
|
||||
)
|
||||
body_text = await response.text()
|
||||
except asyncio.CancelledError:
|
||||
# Cancellation is never converted: a shutting-down event
|
||||
# loop must stop, not read a synthesized API error.
|
||||
raise
|
||||
except NestQuestApiError:
|
||||
raise
|
||||
except Exception as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API {method} {path} transport failure: {err}",
|
||||
status=None,
|
||||
) from err
|
||||
try:
|
||||
return json.loads(body_text)
|
||||
except ValueError as err:
|
||||
raise NestQuestApiError(
|
||||
f"NestQuest API {method} {path} returned malformed "
|
||||
f"JSON: {err}",
|
||||
status=status,
|
||||
) from err
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
|
||||
|
||||
The children registry business layer lives in
|
||||
:mod:`custom_components.nestquest.core.children` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .children import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.children import * # noqa: F401,F403
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package.
|
||||
|
||||
The completion business layer lives in
|
||||
:mod:`custom_components.nestquest.core.completion` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .completion import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.completion import * # noqa: F401,F403
|
||||
@@ -7,7 +7,7 @@ from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
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):
|
||||
@@ -30,11 +30,11 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
) -> FlowResult:
|
||||
"""Handle the user step of the config flow.
|
||||
|
||||
The integration needs no configuration to start (the API
|
||||
connection is set in the options flow), so the user input is
|
||||
stored as-is. The former admin-allowlist copy is gone: with
|
||||
the database removed the allowlist lives in the API service's
|
||||
own store, not on the entry.
|
||||
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)
|
||||
if self._async_in_progress() or self._async_current_entries():
|
||||
@@ -43,4 +43,12 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
|
||||
return self.async_create_entry(title="NestQuest", data=dict(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)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18).
|
||||
"""The shared NestQuest DataUpdateCoordinator (Feature 10).
|
||||
|
||||
ONE refresh pass builds the whole entity snapshot: active children,
|
||||
today's instances with their derived states (open / done / missed via
|
||||
@@ -7,81 +7,54 @@ per-child rollup counts. Every Feature 10 entity reads
|
||||
``coordinator.data`` — no entity queries the database itself, so one
|
||||
refresh updates the whole board coherently.
|
||||
|
||||
Since Feature 18 the snapshot no longer comes from the local database:
|
||||
the coordinator polls the NestQuest API service's panel plane —
|
||||
``GET /api/v1/panel/snapshot`` through
|
||||
:class:`~.api_client.NestQuestApiClient` on its update interval — and
|
||||
RECONSTRUCTS the same core dataclasses the local builder produced
|
||||
(:class:`~.core.snapshot.NestQuestSnapshot`,
|
||||
:class:`~.core.snapshot.ChildDaySnapshot`,
|
||||
:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and
|
||||
``binary_sensor.py`` read ``coordinator.data`` and
|
||||
:func:`~.core.snapshot.instance_payload` exactly as before, so every
|
||||
entity_id and attribute payload shape is unchanged: the panel cards and
|
||||
the four blueprints keep working untouched.
|
||||
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
|
||||
host clock and never UTC, matching the materialization and completion
|
||||
layers.
|
||||
|
||||
One loss is deliberate and documented (D-009 revisited): the panel
|
||||
payload OMITS missed instances, so a snapshot rebuilt from the API can
|
||||
no longer carry them — ``admin_instances`` (``include_missed=True``)
|
||||
now equals ``instances`` (they can hold only open and completed rows).
|
||||
The admin surface for missed quests is the PWA, which reads the API
|
||||
service directly; the integration's entities never needed missed rows
|
||||
for their states or counts (the counts arrive precomputed).
|
||||
The snapshot assembly itself lives in the Home-Assistant-free
|
||||
:mod:`custom_components.nestquest.core.snapshot` module
|
||||
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
|
||||
HA wrapper that resolves the local timezone and reads the settings handed
|
||||
into its constructor, then delegates to that builder, passing the SAME
|
||||
HA-local ``now`` it resolved in one clock read. The API service's panel
|
||||
snapshot route shares the same builder, so both consumers render ONE
|
||||
snapshot shape.
|
||||
|
||||
Failure handling: constructing the client fails fast (ValueError) when
|
||||
the entry has no panel token configured, and a fetch failure raises the
|
||||
typed :class:`~.api_client.NestQuestApiError`. Neither crashes
|
||||
integration setup: the constructor logs a clear message and hands back
|
||||
a client-shaped stub whose refresh raises the same typed error, and
|
||||
``_async_update_data`` converts every typed error into
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the
|
||||
exception a HA ``DataUpdateCoordinator`` records as a failed pass
|
||||
(``last_update_success = False``, ``data = None``), so the entities
|
||||
come up unavailable. The typed error is never swallowed silently: it
|
||||
is logged with its message and chained into the UpdateFailed. (The
|
||||
conversion matters at setup: HA's real
|
||||
``async_config_entry_first_refresh`` wraps any failed pass in
|
||||
``ConfigEntryNotReady``, so a setup that keys on the original
|
||||
exception type would never match it.)
|
||||
Presence is resolved through the SAME engine the materializer uses
|
||||
(:class:`~.presence.PresenceEngine`): schedules and overrides are read
|
||||
with the public DAO methods and the engine's anchor-date arithmetic
|
||||
answers ``is this child at this house today`` (D-004 — never ISO week
|
||||
parity).
|
||||
|
||||
Last-good cache and staleness: every successful pass refreshes a cache
|
||||
of the rebuilt snapshot stamped with the :func:`time.monotonic`
|
||||
instant of the success. A typed failure is checked against that stamp
|
||||
BEFORE the pass is declared failed: within the configured staleness
|
||||
threshold (``CONF_SNAPSHOT_STALENESS``, seconds, default three update
|
||||
intervals) the pass SUCCEEDS with the cached snapshot — the panel
|
||||
keeps rendering last-good data and the entities stay available — and
|
||||
the outage is logged as a warning. Entities go unavailable only once
|
||||
the cache is older than the threshold (or before the first success);
|
||||
a later successful fetch refreshes the cache and the entities recover
|
||||
on their own, without a config-entry reload.
|
||||
``cycle_day`` reports where today falls in the custody cycle of the
|
||||
first active child (display order) that HAS a schedule — 1-based day
|
||||
``N`` of ``cycle_length_weeks * 7``, computed from that schedule's
|
||||
anchor date. ``0`` means no active child has a schedule at all, so
|
||||
the admin header degrades to a harmless zero instead of guessing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import time
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config
|
||||
from .const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
)
|
||||
from .core.db import NestQuestDatabase
|
||||
from .core.settings import NestQuestSettings
|
||||
from .core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
build_snapshot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -92,131 +65,9 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
|
||||
"""Reconstruct the core snapshot dataclasses from the API's JSON.
|
||||
|
||||
The panel snapshot route (``api/routes_panel.py``) returns today's
|
||||
household as plain JSON — ``today_iso``, ``cycle_day``, and the
|
||||
per-child list with precomputed counts and the instances in the
|
||||
documented payload shape. This function maps that shape BACK onto
|
||||
the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by
|
||||
field:
|
||||
|
||||
- the API's ``state`` ``"completed"`` maps back to the core view's
|
||||
``"done"`` (so :func:`~.core.snapshot.instance_payload` renders
|
||||
``"completed"`` again — the round-trip is identity for open and
|
||||
completed rows);
|
||||
- ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``,
|
||||
``window``, ``due_time``, ``icon`` and the per-child counts pass
|
||||
through verbatim;
|
||||
- ``present`` and ``next_present`` pass through verbatim (the API
|
||||
resolved them through the same presence engine server-side);
|
||||
- the instance list is rebuilt IN ORDER, so the panel payload the
|
||||
sensors emit matches the route's order byte for byte.
|
||||
|
||||
Because the route omits missed instances (D-009), a snapshot built
|
||||
here cannot contain any ``missed`` view — see the module docstring.
|
||||
"""
|
||||
children: list[ChildDaySnapshot] = []
|
||||
for child in payload.get("children", []):
|
||||
views = tuple(
|
||||
QuestInstanceView(
|
||||
instance_id=instance["id"],
|
||||
definition_id=instance["definition_id"],
|
||||
child_id=instance["child_id"],
|
||||
title=instance["title"],
|
||||
icon=instance.get("icon"),
|
||||
window=instance["window"],
|
||||
# The API payload does not carry due_date: every row
|
||||
# is TODAY's instance (the route queries
|
||||
# [today, today]), so the view's due_date is the
|
||||
# snapshot's today_iso — the same value the local
|
||||
# builder stamped on every today row.
|
||||
due_date=payload["today_iso"],
|
||||
due_time=instance.get("due_time"),
|
||||
state=(
|
||||
"done" if instance["state"] == "completed" else
|
||||
instance["state"]
|
||||
),
|
||||
overdue=instance["overdue"],
|
||||
completed_at=instance.get("completed_at"),
|
||||
was_on_time=instance.get("on_time"),
|
||||
)
|
||||
for instance in child.get("instances", [])
|
||||
)
|
||||
children.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child["child_id"],
|
||||
child_name=child["child_name"],
|
||||
instances=views,
|
||||
present=child["present"],
|
||||
due_today=child["due_today"],
|
||||
completed_today=child["completed_today"],
|
||||
remaining_today=child["remaining_today"],
|
||||
completion_pct=child["completion_pct"],
|
||||
next_present=child.get("next_present"),
|
||||
)
|
||||
)
|
||||
return NestQuestSnapshot(
|
||||
today_iso=payload["today_iso"],
|
||||
children=tuple(children),
|
||||
cycle_day=payload["cycle_day"],
|
||||
)
|
||||
|
||||
|
||||
class _UnconfiguredApiClient:
|
||||
"""Client-shaped stand-in used when the entry has no panel token.
|
||||
|
||||
Constructing a real :class:`~.api_client.NestQuestApiClient` with an
|
||||
empty token raises ValueError by design (fail fast on a client that
|
||||
could only ever 401). An entry that simply has not been configured
|
||||
yet must not crash integration setup, so the coordinator swaps in
|
||||
this stub: every refresh raises the SAME typed
|
||||
:class:`~.api_client.NestQuestApiError` a transport failure would,
|
||||
carrying a clear "API not configured" message;
|
||||
:meth:`NestQuestCoordinator._async_update_data` converts it to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so
|
||||
the coordinator marks the entities unavailable and the HA standard
|
||||
retry cycle applies. The error is logged, never silent.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
async def get_snapshot(self) -> dict[str, Any]:
|
||||
"""Always raise: there is no configured API to fetch from."""
|
||||
raise NestQuestApiError(
|
||||
"NestQuest API not configured: set the panel token in the "
|
||||
"integration options"
|
||||
)
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""The monotonic clock the staleness math reads.
|
||||
|
||||
A one-line indirection over :func:`time.monotonic` so the staleness
|
||||
tests can drive the clock deterministically (patch THIS function,
|
||||
never the stdlib clock). Monotonic is deliberate: wall-clock jumps
|
||||
(NTP corrections, suspends) must not shorten or extend the window.
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
"""Shared refresh cycle for every NestQuest entity.
|
||||
|
||||
One refresh pass = one ``GET /api/v1/panel/snapshot`` through the
|
||||
API client, reconstructed into the core snapshot dataclasses every
|
||||
entity reads. A failed pass (unconfigured API, transport failure,
|
||||
non-2xx response) raises the typed error out of the client;
|
||||
``_async_update_data`` converts it to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the
|
||||
exception HA's coordinator machinery records as a failed pass: the
|
||||
coordinator's ``data`` keeps its last good value while
|
||||
``last_update_success`` goes False — HA marks the entities
|
||||
unavailable until a later pass succeeds. An unexpected
|
||||
non-typed error still propagates out of ``_async_update_data``
|
||||
and is logged (with its traceback) by HA's machinery, the same way.
|
||||
|
||||
Forced refreshes (the service paths pushing an immediate update
|
||||
after a completion) are SERIALIZED per entry: overlapping
|
||||
complete/uncomplete calls must not interleave their snapshot
|
||||
@@ -224,15 +75,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
newer one and leave entities wrong until the next poll. Real
|
||||
HA's coordinator already serializes internally; the lock keeps
|
||||
the same guarantee on the stand-in and costs nothing upstream.
|
||||
|
||||
Last-good cache: a successful pass stamps the rebuilt snapshot
|
||||
with the :func:`~.coordinator._monotonic` instant of the success;
|
||||
a typed failure within the configured staleness threshold
|
||||
(``staleness_seconds``, from ``CONF_SNAPSHOT_STALENESS``) serves
|
||||
that cached snapshot instead of failing the pass, so a short API
|
||||
outage keeps the board up. Past the threshold — or before the
|
||||
first success — the pass fails as above, and a later successful
|
||||
pass refreshes the cache and recovers the entities on its own.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -240,9 +82,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry_id: str,
|
||||
api_client: NestQuestApiClient,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
update_interval_seconds: int | None = None,
|
||||
staleness_seconds: int | None = None,
|
||||
) -> None:
|
||||
interval = update_interval_seconds
|
||||
if (
|
||||
@@ -251,19 +93,6 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
or interval < MIN_UPDATE_INTERVAL
|
||||
):
|
||||
interval = DEFAULT_UPDATE_INTERVAL
|
||||
staleness = staleness_seconds
|
||||
if (
|
||||
not isinstance(staleness, int)
|
||||
or isinstance(staleness, bool)
|
||||
or staleness < interval
|
||||
):
|
||||
# A missing or nonsensical threshold falls back to the
|
||||
# shipped default; a configured-but-too-short one (below
|
||||
# even one poll interval) falls back to whichever of
|
||||
# default/interval spans at least one failed poll, so the
|
||||
# cache is never dead on arrival. (The options flow
|
||||
# validates the same rule at the form.)
|
||||
staleness = max(DEFAULT_SNAPSHOT_STALENESS, interval)
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
@@ -271,16 +100,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
update_interval=datetime.timedelta(seconds=interval),
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.api_client = api_client
|
||||
self.database = database
|
||||
self.settings = settings
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
#: The configured last-good window, in SECONDS (const.py:
|
||||
#: CONF_SNAPSHOT_STALENESS / DEFAULT_SNAPSHOT_STALENESS).
|
||||
self._staleness_seconds = staleness
|
||||
#: The last-good cache: the snapshot from the most recent
|
||||
#: SUCCESSFUL fetch and the :func:`time.monotonic` instant it
|
||||
#: landed. Both stay None until the first success.
|
||||
self._last_good_snapshot: NestQuestSnapshot | None = None
|
||||
self._last_good_monotonic: float | None = None
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
"""One refresh at a time; a queued refresh runs AFTER the
|
||||
@@ -289,98 +111,12 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
async with self._refresh_lock:
|
||||
await super().async_refresh()
|
||||
|
||||
def _local_now(self) -> datetime.datetime:
|
||||
time_zone = ZoneInfo(self.hass.config.time_zone)
|
||||
return datetime.datetime.now(time_zone)
|
||||
|
||||
async def _async_update_data(self) -> NestQuestSnapshot:
|
||||
"""One refresh pass: fetch the panel snapshot and rebuild it.
|
||||
|
||||
A successful pass rebuilds the snapshot and refreshes the
|
||||
last-good cache (the snapshot plus the
|
||||
:func:`time.monotonic` instant of the success).
|
||||
|
||||
A typed API failure (unconfigured token stub, transport
|
||||
failure, non-2xx response) is first checked against that
|
||||
stamp: WITHIN the configured staleness threshold (elapsed time
|
||||
since the last successful fetch, inclusive of the threshold
|
||||
itself) the pass SUCCEEDS with the cached snapshot — the panel
|
||||
keeps rendering last-good data, the entities stay available —
|
||||
and the outage is logged as a clear warning (never silent).
|
||||
With no cache yet, or past the threshold, the typed error is
|
||||
converted to
|
||||
:class:`~homeassistant.helpers.update_coordinator.UpdateFailed`
|
||||
— the exception HA's coordinator machinery records as a failed
|
||||
pass (``last_update_success = False``, ``data`` unchanged) so
|
||||
entities go unavailable and the setup-time first refresh
|
||||
arrives as ``ConfigEntryNotReady`` — with the typed error
|
||||
logged and chained. Anything else still propagates.
|
||||
"""
|
||||
try:
|
||||
payload = await self.api_client.get_snapshot()
|
||||
except NestQuestApiError as err:
|
||||
now = _monotonic()
|
||||
cached = self._last_good_snapshot
|
||||
fetched_at = self._last_good_monotonic
|
||||
if (
|
||||
cached is not None
|
||||
and fetched_at is not None
|
||||
and now - fetched_at <= self._staleness_seconds
|
||||
):
|
||||
LOGGER.warning(
|
||||
"NestQuest API refresh failed (%s); serving the "
|
||||
"last-good snapshot fetched %.0f s ago, within the "
|
||||
"%d s staleness threshold",
|
||||
err,
|
||||
now - fetched_at,
|
||||
self._staleness_seconds,
|
||||
)
|
||||
return cached
|
||||
LOGGER.error(
|
||||
"NestQuest coordinator refresh failed: %s", err
|
||||
)
|
||||
raise UpdateFailed(str(err)) from err
|
||||
snapshot = snapshot_from_api_payload(payload)
|
||||
self._last_good_snapshot = snapshot
|
||||
self._last_good_monotonic = _monotonic()
|
||||
return snapshot
|
||||
|
||||
|
||||
def coordinator_client_from_entry(
|
||||
hass: HomeAssistant, entry: Any, *, session_factory: Any = None
|
||||
) -> Any:
|
||||
"""Build the refresh client for a config entry, tolerating no token.
|
||||
|
||||
Reads ``(base_url, panel_token)`` off the entry and constructs the
|
||||
real client. An entry with no panel token configured (the shipped
|
||||
default is the empty string) makes the constructor raise ValueError
|
||||
by design; integration setup must not crash on an unconfigured
|
||||
entry, so this factory swaps in the typed-error stub instead and
|
||||
logs ONE clear message. The ValueError itself is never re-raised
|
||||
past this point — it is converted, with the reason logged.
|
||||
|
||||
``session_factory`` overrides where the HTTP session comes from
|
||||
(production: HA's shared aiohttp session via
|
||||
:func:`~.api_client.client_from_entry`); it exists so tests can
|
||||
inject a stub transport without importing aiohttp — the mock-only
|
||||
test harness has no ``homeassistant.helpers.aiohttp_client``.
|
||||
"""
|
||||
base_url, panel_token = resolve_api_config(entry)
|
||||
if not isinstance(panel_token, str) or not panel_token.strip():
|
||||
LOGGER.warning(
|
||||
"NestQuest API panel token is not configured for entry %s: "
|
||||
"entities will stay unavailable until a token is set in the "
|
||||
"integration options",
|
||||
entry.entry_id,
|
||||
now = self._local_now()
|
||||
return await build_snapshot(
|
||||
self.database, self.settings, now
|
||||
)
|
||||
return _UnconfiguredApiClient()
|
||||
if session_factory is not None:
|
||||
return NestQuestApiClient(
|
||||
base_url, panel_token, session_factory(hass)
|
||||
)
|
||||
from custom_components.nestquest.api_client import client_from_entry
|
||||
|
||||
return client_from_entry(hass, entry)
|
||||
|
||||
|
||||
#: Test seam: entry_id → client, consulted by the conftest's client
|
||||
#: factory BEFORE the production path. Always empty in production
|
||||
#: (nothing in the integration writes to it); tests register scripted
|
||||
#: clients through it and clear it between tests.
|
||||
_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {}
|
||||
|
||||
@@ -20,41 +20,44 @@ TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
||||
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||
|
||||
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
|
||||
# panel and admin cards listen for these; since Feature 18 the
|
||||
# completed/uncompleted/day-complete transitions are published by the
|
||||
# API service on its SSE stream and re-fired on the bus by the
|
||||
# integration's subscription, and Feature 11's nightly sweep fires
|
||||
# quest_missed.
|
||||
# panel and admin cards listen for these; Feature 10 fires completed,
|
||||
# uncompleted, and child_day_complete from the service paths, and
|
||||
# Feature 11's nightly sweep fires quest_missed.
|
||||
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
|
||||
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
|
||||
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
|
||||
|
||||
#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2).
|
||||
#: Since Feature 18 only the panel completion service remains: the admin
|
||||
#: surface is the PWA and the API service's admin routes, so the former
|
||||
#: HA admin services (uncomplete, quest-definition and presence
|
||||
#: management, CSV export, child management, regenerate) are gone.
|
||||
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
|
||||
#: plus the existing Feature 07 regenerate service).
|
||||
SERVICE_COMPLETE_QUEST = "complete_quest"
|
||||
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
|
||||
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
|
||||
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
|
||||
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
|
||||
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
|
||||
SERVICE_MANAGE_CHILD = "manage_child"
|
||||
SERVICE_REGENERATE = "regenerate"
|
||||
|
||||
DOMAIN_SERVICES: tuple[str, ...] = (
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
)
|
||||
|
||||
SQLITE_DB_FILENAME = "nestquest.db"
|
||||
|
||||
# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace
|
||||
# dashboard registered on setup whose config carries the bundle's
|
||||
# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the
|
||||
# views are generated at render time from the household roster. The
|
||||
# url_path IS the domain: the stable key idempotency is keyed on (a
|
||||
# reload or a second entry never duplicates it, and a dashboard already
|
||||
# registered under it — ours or the user's — is left untouched).
|
||||
DASHBOARD_URL_PATH = DOMAIN
|
||||
DASHBOARD_TITLE = "NestQuest"
|
||||
DASHBOARD_ICON = "mdi:party-popper"
|
||||
DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party"
|
||||
|
||||
# Configuration keys and default values
|
||||
CONF_HORIZON_DAYS = "horizon_days"
|
||||
DEFAULT_HORIZON_DAYS = 14
|
||||
@@ -78,21 +81,6 @@ CONF_UPDATE_INTERVAL = "update_interval"
|
||||
DEFAULT_UPDATE_INTERVAL = 300
|
||||
MIN_UPDATE_INTERVAL = 30
|
||||
|
||||
# Last-good snapshot staleness (Feature 18), in SECONDS: how long the
|
||||
# coordinator keeps SERVING the last good snapshot after the API stops
|
||||
# answering. A failed poll whose elapsed time since the last successful
|
||||
# fetch is within the threshold returns the cached snapshot (the panel
|
||||
# keeps rendering last-good data, entities stay available); past it the
|
||||
# poll fails the HA way and the entities go unavailable. The default is
|
||||
# 3x the default update interval (900 s = 15 minutes): three consecutive
|
||||
# failed five-minute polls before the board is declared stale — enough
|
||||
# to ride out an API service restart, short enough not to render a
|
||||
# days-old board all day. Validation floor: the threshold must be at
|
||||
# least the configured update interval, or the cache could never span
|
||||
# even one failed poll.
|
||||
CONF_SNAPSHOT_STALENESS = "snapshot_staleness"
|
||||
DEFAULT_SNAPSHOT_STALENESS = 3 * DEFAULT_UPDATE_INTERVAL
|
||||
|
||||
# Notification automations (Feature 11). The notify target is a free-text
|
||||
# Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the
|
||||
# household fills in — never a hard-coded personal target; the times are
|
||||
@@ -113,32 +101,6 @@ CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
|
||||
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
||||
DEFAULT_AUTOMATION_ENABLED = True
|
||||
|
||||
# Panel-plane API client (Feature 18): the integration reaches the
|
||||
# NestQuest API service (Feature 16) over HTTP instead of the database.
|
||||
# The base URL points at the API service host serving ``/api/v1/panel/*``
|
||||
# (uvicorn's default host/port is the sensible starting point); the panel
|
||||
# service token is the static Bearer credential the panel plane requires
|
||||
# (``NESTQUEST_PANEL_TOKEN`` on the API side). Both are stored on the
|
||||
# entry like every other setting — options first, then the entry-data
|
||||
# copy, then defaults. The TOKEN IS A SECRET: it is never logged, never
|
||||
# echoed into error messages, and never committed. The shipped default
|
||||
# token IS the empty string (DEFAULT_PANEL_TOKEN) — an entry with no
|
||||
# configured token is therefore NOT a working client: the options flow
|
||||
# accepts an empty token as "not configured yet" and only rejects
|
||||
# malformed (padded/whitespace-only/non-string) values — the actual
|
||||
# non-emptiness guard is enforced at client construction, where
|
||||
# NestQuestApiClient fails fast on an empty/whitespace token
|
||||
# (api_client.py). So a caller building the client from a bare
|
||||
# entry must either ensure a token is configured first or handle the
|
||||
# construction ValueError and surface an "API not configured" state —
|
||||
# an unconfigured entry never yields a client that can only 401.
|
||||
# This mirrors the API service's fail-closed stance (api/config.py: no
|
||||
# default token exists).
|
||||
CONF_API_BASE_URL = "api_base_url"
|
||||
DEFAULT_API_BASE_URL = "http://127.0.0.1:8000"
|
||||
CONF_PANEL_TOKEN = "panel_token"
|
||||
DEFAULT_PANEL_TOKEN = ""
|
||||
|
||||
# Quest windows (D-008): a definition may declare several day windows;
|
||||
# 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
|
||||
|
||||
@@ -41,38 +41,4 @@ class MetaStateDao:
|
||||
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
async def claim(self, key: str, value: str) -> bool:
|
||||
"""Atomically set ``key`` to ``value`` unless it already is.
|
||||
|
||||
The single upsert statement carries a ``WHERE`` guard on the
|
||||
conflict path (``t.value IS NOT excluded.value``), so the
|
||||
read-compare-write is ONE SQLite statement — atomic at the
|
||||
DATABASE level, not just within one process: two connections
|
||||
(two processes sharing one SQLite file) cannot both observe
|
||||
"not yet set" and both write, because the second writer's
|
||||
statement re-evaluates the guard against the first writer's
|
||||
committed value and matches zero rows.
|
||||
|
||||
Returns True only when THIS call changed the row: the first
|
||||
claimant wins and every later claimant of the same value
|
||||
returns False. A DIFFERENT value always wins (an advance, e.g.
|
||||
the sweep watermark moving to a new date) and re-arms the
|
||||
same-value loser check for the new value.
|
||||
|
||||
Runs under the shared connection lock and inside one
|
||||
transaction, like :meth:`set`; ``cursor.rowcount`` (1 for a
|
||||
matched write, 0 for a guarded no-op) is captured by the
|
||||
wrapper on the executor thread, so no sqlite3 object crosses
|
||||
back to the event loop.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
result = await self._database.execute(
|
||||
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = excluded.value "
|
||||
f"WHERE {_TABLE}.value IS NOT excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
return result.rowcount == 1
|
||||
)
|
||||
@@ -270,57 +270,6 @@ class PresenceOverridesDao:
|
||||
)
|
||||
return [_override_from_row(row) for row in rows]
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
*,
|
||||
child_id: int | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> list[PresenceOverrideRecord]:
|
||||
"""Return the household's overrides, optionally narrowed.
|
||||
|
||||
Every filter is optional and they combine: ``child_id`` keeps
|
||||
that child's overrides only (an unknown child raises ValueError
|
||||
rather than reading as "no overrides"); ``start`` keeps
|
||||
overrides ending on or after it and ``end`` keeps overrides
|
||||
starting on or before it, so both together select the overrides
|
||||
overlapping the closed range ``[start, end]`` (the
|
||||
:meth:`list_by_child_and_range` boundary rule). Dates are
|
||||
strict YYYY-MM-DD and ``end`` must not precede ``start``.
|
||||
Ordered stably by start date, then child id, then id.
|
||||
"""
|
||||
if start is not None:
|
||||
_validate_date(start, "start")
|
||||
if end is not None:
|
||||
_validate_date(end, "end")
|
||||
if start is not None and end is not None and end < start:
|
||||
raise ValueError(
|
||||
f"end must be on or after start, got {end!r} < {start!r}"
|
||||
)
|
||||
clauses: list[str] = []
|
||||
params: list[object] = []
|
||||
if child_id is not None:
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
clauses.append("child_id = ?")
|
||||
params.append(child_id)
|
||||
if start is not None:
|
||||
clauses.append("end_date >= ?")
|
||||
params.append(start)
|
||||
if end is not None:
|
||||
clauses.append("start_date <= ?")
|
||||
params.append(end)
|
||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides{where} "
|
||||
"ORDER BY start_date, child_id, id",
|
||||
tuple(params),
|
||||
)
|
||||
return [_override_from_row(row) for row in rows]
|
||||
|
||||
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
|
||||
"""Return one override by id, or None when it does not exist."""
|
||||
row = await self._database.fetch_one(
|
||||
|
||||
@@ -105,19 +105,6 @@ def _validate_window(window: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _validate_skip_on_away(value: object) -> int:
|
||||
"""Return ``value`` as 0/1, raising ValueError for anything else.
|
||||
|
||||
The fresh-schema CHECK constraint enforces this in SQLite, but a
|
||||
migrated database gained the column through ``ADD COLUMN`` without
|
||||
it, so every DAO write path validates here instead. Accepts a bool
|
||||
or the ints 0/1; floats, strings and other ints are rejected.
|
||||
"""
|
||||
if not isinstance(value, int) or value not in (0, 1):
|
||||
raise ValueError(f"skip_on_away must be 0 or 1, got {value!r}")
|
||||
return int(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleRuleRecord:
|
||||
"""One row of ``schedule_rules``."""
|
||||
@@ -165,7 +152,6 @@ class QuestDefinitionRecord:
|
||||
due_time: str | None
|
||||
is_active: bool
|
||||
created_at: str
|
||||
skip_on_away: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -201,7 +187,7 @@ _RULE_COLUMNS = (
|
||||
)
|
||||
_DEFINITION_COLUMNS = (
|
||||
"id, title, description, icon, schedule_rule_id, due_time, "
|
||||
"is_active, created_at, skip_on_away"
|
||||
"is_active, created_at"
|
||||
)
|
||||
_WINDOW_COLUMNS = "definition_id, window, due_time"
|
||||
|
||||
@@ -230,7 +216,6 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
|
||||
due_time=row[5],
|
||||
is_active=bool(row[6]),
|
||||
created_at=row[7],
|
||||
skip_on_away=bool(row[8]),
|
||||
)
|
||||
|
||||
|
||||
@@ -647,7 +632,6 @@ class QuestDefinitionsDao:
|
||||
due_time: str | None = None,
|
||||
is_active: bool = True,
|
||||
assignee_child_ids: list[int] | None = None,
|
||||
skip_on_away: bool = True,
|
||||
) -> QuestDefinitionRecord:
|
||||
"""Insert one definition and return the record as stored.
|
||||
|
||||
@@ -660,7 +644,6 @@ class QuestDefinitionsDao:
|
||||
empty list or None creates an unassigned definition, which
|
||||
callers may fill via :meth:`add_assignee`.
|
||||
"""
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
await self._validate_rule_exists(schedule_rule_id)
|
||||
@@ -669,8 +652,7 @@ class QuestDefinitionsDao:
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO quest_definitions (title, description, "
|
||||
"icon, schedule_rule_id, due_time, is_active, "
|
||||
"created_at, skip_on_away) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
title,
|
||||
description,
|
||||
@@ -679,7 +661,6 @@ class QuestDefinitionsDao:
|
||||
due_time,
|
||||
int(is_active),
|
||||
created_at,
|
||||
skip_on_away_value,
|
||||
),
|
||||
)
|
||||
definition_id = result.lastrowid
|
||||
@@ -703,7 +684,6 @@ class QuestDefinitionsDao:
|
||||
*,
|
||||
description: str | None = None,
|
||||
icon: str | None = None,
|
||||
skip_on_away: bool = True,
|
||||
) -> QuestDefinitionSnapshot:
|
||||
"""Insert a rule, definition, assignees and windows atomically.
|
||||
|
||||
@@ -733,7 +713,6 @@ class QuestDefinitionsDao:
|
||||
_validate_date(schedule_rule.start_date, "start_date")
|
||||
if schedule_rule.end_date is not None:
|
||||
_validate_date(schedule_rule.end_date, "end_date")
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
for child_id in assignee_child_ids:
|
||||
@@ -742,18 +721,8 @@ class QuestDefinitionsDao:
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO quest_definitions (title, description, "
|
||||
"icon, schedule_rule_id, due_time, is_active, "
|
||||
"created_at, skip_on_away) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
rule_id,
|
||||
None,
|
||||
1,
|
||||
created_at,
|
||||
skip_on_away_value,
|
||||
),
|
||||
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(title, description, icon, rule_id, None, 1, created_at),
|
||||
)
|
||||
definition_id = result.lastrowid
|
||||
for child_id in assignee_child_ids:
|
||||
@@ -795,10 +764,8 @@ class QuestDefinitionsDao:
|
||||
icon: str | None | object = _UNSET,
|
||||
rule: ScheduleRuleStorage | object = _UNSET,
|
||||
windows: list[tuple[str, str | None]] | object = _UNSET,
|
||||
skip_on_away: bool | object = _UNSET,
|
||||
assignee_child_ids: list[int] | object = _UNSET,
|
||||
) -> QuestDefinitionSnapshot:
|
||||
"""Edit a definition's metadata, rule, windows and assignees atomically.
|
||||
"""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
|
||||
@@ -807,10 +774,9 @@ class QuestDefinitionsDao:
|
||||
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
|
||||
NULL so optional metadata (description, icon) can be cleared.
|
||||
|
||||
- ``title``/``description``/``icon``/``skip_on_away`` update the
|
||||
definition row in place; the definition-level ``due_time``
|
||||
column is never written (per-window due times supersede it,
|
||||
D-008).
|
||||
- ``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
|
||||
@@ -819,19 +785,13 @@ class QuestDefinitionsDao:
|
||||
are deleted and the given set re-inserted, so missing windows
|
||||
are added, changed due times updated and removed windows
|
||||
dropped.
|
||||
- ``assignee_child_ids`` REPLACES the whole assignee set: every
|
||||
id is validated as existing-and-active BEFORE any write (the
|
||||
same check :meth:`add_assignee` applies), then links for
|
||||
children no longer listed are removed and links for newly
|
||||
listed children inserted; unchanged links are left alone.
|
||||
|
||||
Activation is untouched. Raises ValueError when the definition
|
||||
does not exist or an assignee is rejected; either way the
|
||||
transaction rolls back and nothing is written. Edits change future instances only: no
|
||||
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.
|
||||
"""
|
||||
if skip_on_away is not _UNSET:
|
||||
skip_on_away = _validate_skip_on_away(skip_on_away)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
definition = await self.get(definition_id)
|
||||
@@ -839,9 +799,6 @@ class QuestDefinitionsDao:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} does not exist"
|
||||
)
|
||||
if assignee_child_ids is not _UNSET:
|
||||
for child_id in assignee_child_ids:
|
||||
await self._validate_assignable_child(child_id)
|
||||
assignments: list[str] = []
|
||||
parameters: list[object] = []
|
||||
for column, value in (
|
||||
@@ -852,9 +809,6 @@ class QuestDefinitionsDao:
|
||||
if value is not _UNSET:
|
||||
assignments.append(f"{column} = ?")
|
||||
parameters.append(value)
|
||||
if skip_on_away is not _UNSET:
|
||||
assignments.append("skip_on_away = ?")
|
||||
parameters.append(skip_on_away)
|
||||
if assignments:
|
||||
parameters.append(definition_id)
|
||||
await self._database.execute(
|
||||
@@ -890,10 +844,6 @@ class QuestDefinitionsDao:
|
||||
"VALUES (?, ?, ?)",
|
||||
(definition_id, window, due_time),
|
||||
)
|
||||
if assignee_child_ids is not _UNSET:
|
||||
await self._replace_assignees(
|
||||
definition_id, assignee_child_ids
|
||||
)
|
||||
updated = await self.get(definition_id)
|
||||
assignees = await self.list_assignees(definition_id)
|
||||
window_records = await self.list_windows(definition_id)
|
||||
@@ -909,36 +859,6 @@ class QuestDefinitionsDao:
|
||||
windows=window_records,
|
||||
)
|
||||
|
||||
async def _replace_assignees(
|
||||
self, definition_id: int, child_ids: list[int]
|
||||
) -> None:
|
||||
"""Make the definition's assignee links exactly ``child_ids``.
|
||||
|
||||
Removes the links for children not in ``child_ids`` and inserts
|
||||
the missing ones. MUST be called inside the connection lock and
|
||||
an open transaction, after the ids were validated as assignable.
|
||||
"""
|
||||
rows = await self._database.fetch_all(
|
||||
"SELECT child_id FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
current = {row[0] for row in rows}
|
||||
wanted = set(child_ids)
|
||||
for child_id in sorted(current - wanted):
|
||||
await self._database.execute(
|
||||
"DELETE FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ? AND child_id = ?",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
for child_id in child_ids:
|
||||
if child_id not in current:
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_definition_assignees "
|
||||
"(definition_id, child_id) VALUES (?, ?)",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
|
||||
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
|
||||
"""Raise ValueError unless the rule exists.
|
||||
|
||||
@@ -987,13 +907,6 @@ class QuestDefinitionsDao:
|
||||
)
|
||||
return [_definition_from_row(row) for row in rows]
|
||||
|
||||
async def list_all(self) -> list[QuestDefinitionRecord]:
|
||||
"""Return every definition, active AND inactive, oldest first."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions ORDER BY id"
|
||||
)
|
||||
return [_definition_from_row(row) for row in rows]
|
||||
|
||||
async def _snapshots_for(
|
||||
self, definitions: list[QuestDefinitionRecord]
|
||||
) -> list[QuestDefinitionSnapshot]:
|
||||
@@ -1038,18 +951,6 @@ class QuestDefinitionsDao:
|
||||
definitions = await self.list_active()
|
||||
return await self._snapshots_for(definitions)
|
||||
|
||||
async def list_snapshots_all(self) -> list[QuestDefinitionSnapshot]:
|
||||
"""Return every definition (active AND inactive) with its rule,
|
||||
assignees and windows, all read inside one locked transaction.
|
||||
|
||||
Mirrors :meth:`list_snapshots_active` over :meth:`list_all`
|
||||
(oldest first), with the same coherence guarantee.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
definitions = await self.list_all()
|
||||
return await self._snapshots_for(definitions)
|
||||
|
||||
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
|
||||
"""Return every active definition's snapshot WITHOUT locking.
|
||||
|
||||
@@ -1089,16 +990,13 @@ class QuestDefinitionsDao:
|
||||
description: str | None | object = _UNSET,
|
||||
icon: str | None | object = _UNSET,
|
||||
due_time: str | None | object = _UNSET,
|
||||
skip_on_away: bool | None = None,
|
||||
) -> int:
|
||||
"""Update the given fields; returns rows updated (0 if absent).
|
||||
|
||||
Arguments default to the module sentinel ``_UNSET`` meaning
|
||||
"leave this column alone"; passing ``None`` explicitly writes
|
||||
SQL NULL, so callers can remove optional metadata
|
||||
(description, icon, due_time). ``skip_on_away`` is NOT NULL,
|
||||
so its ``None`` default leaves it unchanged; a supplied value
|
||||
must be 0/1 (a bool). Assignment and activation are
|
||||
(description, icon, due_time). Assignment and activation are
|
||||
deliberately NOT settable here: they have their own explicit
|
||||
operations (:meth:`add_assignee`, :meth:`remove_assignee`,
|
||||
:meth:`set_active`) so they stay separately loggable and
|
||||
@@ -1115,9 +1013,6 @@ class QuestDefinitionsDao:
|
||||
if value is not _UNSET:
|
||||
assignments.append(f"{column} = ?")
|
||||
parameters.append(value)
|
||||
if skip_on_away is not None:
|
||||
assignments.append("skip_on_away = ?")
|
||||
parameters.append(_validate_skip_on_away(skip_on_away))
|
||||
if not assignments:
|
||||
return 0
|
||||
parameters.append(definition_id)
|
||||
|
||||
@@ -18,8 +18,7 @@ The walk is deterministic and idempotent:
|
||||
: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 (or every assignee, when the definition's
|
||||
``skip_on_away`` is off) and each declared window, one instance 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).
|
||||
@@ -209,8 +208,7 @@ async def materialize(
|
||||
|
||||
definitions = [
|
||||
(snapshot.definition.id, _decode_rule(snapshot),
|
||||
snapshot.assignees, snapshot.windows,
|
||||
snapshot.definition.skip_on_away)
|
||||
snapshot.assignees, snapshot.windows)
|
||||
for snapshot in snapshots
|
||||
]
|
||||
|
||||
@@ -231,15 +229,13 @@ async def materialize(
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
iso = cursor.isoformat()
|
||||
for (
|
||||
definition_id, rule, assignees, windows, skip_on_away
|
||||
) in definitions:
|
||||
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 skip_on_away and not engine.is_present(child.id, cursor):
|
||||
if not engine.is_present(child.id, cursor):
|
||||
continue
|
||||
for window in windows:
|
||||
written = await instances.upsert_if_valid(
|
||||
|
||||
@@ -30,9 +30,8 @@ widened (definition_id, child_id, due_date, window) key, and migration
|
||||
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
|
||||
column. Migration 7 adds the generic ``nestquest_meta_state``
|
||||
key/value table for integration-owned bookkeeping (Feature 11's
|
||||
missed-sweep watermark). Migration 8 adds the ``skip_on_away``
|
||||
column to ``quest_definitions`` (Feature 22), defaulting to 1. Each
|
||||
callable migration runs inside its own transaction
|
||||
missed-sweep watermark). Each callable migration runs inside its own
|
||||
transaction
|
||||
together with its version stamp, so a crash mid-step rolls the
|
||||
statements and the stamp back together.
|
||||
|
||||
@@ -401,28 +400,6 @@ async def _add_actor_child_id(
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _add_skip_on_away(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Add the ``skip_on_away`` column to ``quest_definitions``.
|
||||
|
||||
Strictly additive: ``ALTER TABLE ... ADD COLUMN`` with a NOT NULL
|
||||
DEFAULT 1, so existing rows read as 1 (skip absent dates — the
|
||||
behaviour before the flag existed). Fresh databases already carry
|
||||
the column from the canonical DDL, so the ALTER is guarded and the
|
||||
step only stamps there.
|
||||
"""
|
||||
async with database.transaction():
|
||||
if not await _table_has_column(
|
||||
database, "quest_definitions", "skip_on_away"
|
||||
):
|
||||
await database.execute(
|
||||
"ALTER TABLE quest_definitions ADD COLUMN "
|
||||
"skip_on_away INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
|
||||
|
||||
#: The ordered migration list. Append-only: never edit an applied
|
||||
#: entry, add the next one instead. (Migration 1's content was
|
||||
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
||||
@@ -446,10 +423,6 @@ MIGRATIONS: Sequence[MigrationStep] = [
|
||||
# fresh databases (which already made it in migration 1) and
|
||||
# pre-v7 databases. Strictly additive; no existing table changes.
|
||||
list(SCHEMA_V7_META_STATE_DDL),
|
||||
# Migration 8 (Feature 22): the skip_on_away flag on
|
||||
# quest_definitions — an additive column defaulting to 1, so every
|
||||
# pre-existing definition keeps today's skip-when-absent behaviour.
|
||||
_add_skip_on_away,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -22,10 +22,6 @@ the children layer owns that guardrail). Deleting a nonexistent
|
||||
override raises ValueError prefixed ``override_id:`` so a caller can
|
||||
distinguish "unknown id" from a rejected argument the same way the
|
||||
quest-definitions layer prefixes ``definition_id:``.
|
||||
|
||||
The admin plane's presence READS go through here too
|
||||
(:func:`get_presence_schedule`, :func:`list_presence_overrides`), so a
|
||||
reader never touches the DAO directly either.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -134,42 +130,6 @@ async def create_presence_override(
|
||||
return record
|
||||
|
||||
|
||||
async def get_presence_schedule(
|
||||
database: NestQuestDatabase, child_id: int
|
||||
) -> PresenceSchedule | None:
|
||||
"""Return the child's presence schedule decoded, or None when unset.
|
||||
|
||||
None means the child exists and has no schedule row — present
|
||||
every day (Feature 05). An unknown child raises ValueError
|
||||
(``child N does not exist``) rather than reading as present.
|
||||
"""
|
||||
record = await PresenceSchedulesDao(database).get_by_child(child_id)
|
||||
if record is None:
|
||||
return None
|
||||
return PresenceSchedule.decode(
|
||||
record.child_id, record.anchor_date, record.pattern
|
||||
)
|
||||
|
||||
|
||||
async def list_presence_overrides(
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
child_id: int | None = None,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
) -> list[PresenceOverrideRecord]:
|
||||
"""Return the household's presence overrides, optionally filtered.
|
||||
|
||||
``child_id`` narrows to one child (unknown: ``child N does not
|
||||
exist``); ``start``/``end`` keep the overrides overlapping that
|
||||
closed range (strict YYYY-MM-DD, ``end >= start``). Ordered by
|
||||
start date, then child id, then id.
|
||||
"""
|
||||
return await PresenceOverridesDao(database).list_filtered(
|
||||
child_id=child_id, start=start, end=end
|
||||
)
|
||||
|
||||
|
||||
async def delete_presence_override(
|
||||
database: NestQuestDatabase,
|
||||
override_id: int,
|
||||
|
||||
@@ -4,9 +4,8 @@ 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 (which may also replace the
|
||||
whole assignee set), :func:`assign_child` and
|
||||
:func:`unassign_child` the single-child assignment paths, and
|
||||
: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).
|
||||
|
||||
@@ -192,17 +191,6 @@ def _validate_definition_id(value: object) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _validate_skip_on_away(value: object) -> bool:
|
||||
"""Reject a non-bool ``skip_on_away`` before any write.
|
||||
|
||||
The DAO's ``int()`` would otherwise silently coerce strings and
|
||||
numerics ("0", 1) into a state the caller never asked for.
|
||||
"""
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"skip_on_away must be a real bool, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools included) before any lookup.
|
||||
|
||||
@@ -224,7 +212,6 @@ async def create_quest_definition(
|
||||
*,
|
||||
description: str | None = None,
|
||||
icon: str | None = None,
|
||||
skip_on_away: bool = True,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Create a quest definition (rule + assignees + windows) atomically.
|
||||
|
||||
@@ -244,7 +231,6 @@ async def create_quest_definition(
|
||||
window_specs = _normalize_windows(windows)
|
||||
description_value = _validate_text(description, "description")
|
||||
icon_value = _validate_text(icon, "icon")
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
|
||||
storage = schedule_rule_to_storage(rule)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
@@ -257,7 +243,6 @@ async def create_quest_definition(
|
||||
window_specs,
|
||||
description=description_value,
|
||||
icon=icon_value,
|
||||
skip_on_away=skip_on_away_value,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's assignee check names only the child; re-raise so the
|
||||
@@ -286,12 +271,10 @@ async def edit_quest_definition(
|
||||
icon: str | None | object = _UNSET,
|
||||
rule: ScheduleRule | object = _UNSET,
|
||||
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
||||
skip_on_away: bool | None = None,
|
||||
assignee_child_ids: list[int] | object = _UNSET,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Edit a quest definition's metadata, rule, windows and assignees atomically.
|
||||
"""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
|
||||
@@ -302,16 +285,9 @@ async def edit_quest_definition(
|
||||
``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. A provided
|
||||
``skip_on_away`` (a real bool) replaces the stored flag; ``None``
|
||||
leaves it unchanged. A provided ``assignee_child_ids`` REPLACES the
|
||||
whole assignee set under create's contract (a non-empty list of
|
||||
distinct plain ints, each an existing ACTIVE child — the DAO checks
|
||||
that inside the edit's transaction): newly listed children are
|
||||
assigned and unlisted ones unassigned. Raises ValueError when the
|
||||
definition does not exist or any argument is rejected; a rejection
|
||||
writes nothing, so the stored assignees (and every other field)
|
||||
stay unchanged.
|
||||
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,
|
||||
@@ -355,14 +331,6 @@ async def edit_quest_definition(
|
||||
if windows is not _UNSET:
|
||||
window_specs = _normalize_windows(windows)
|
||||
|
||||
assignee_ids: list[int] | object = _UNSET
|
||||
if assignee_child_ids is not _UNSET:
|
||||
assignee_ids = _validate_assignee_ids(assignee_child_ids)
|
||||
|
||||
skip_on_away_value: bool | object = _UNSET
|
||||
if skip_on_away is not None:
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
snapshot = await dao.edit_definition(
|
||||
@@ -372,18 +340,14 @@ async def edit_quest_definition(
|
||||
icon=icon_value,
|
||||
rule=rule_storage,
|
||||
windows=window_specs,
|
||||
skip_on_away=skip_on_away_value,
|
||||
assignee_child_ids=assignee_ids,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's missing-definition and assignee errors name only the
|
||||
# id; re-raise so the public contract names the field the caller
|
||||
# passed (matching create/assign/unassign/deactivate).
|
||||
# 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
|
||||
if message.startswith("child "):
|
||||
raise ValueError(f"assignee_child_ids: {message}") from error
|
||||
raise
|
||||
|
||||
decoded_rule = schedule_rule_from_storage(
|
||||
@@ -594,21 +558,6 @@ async def list_active_definitions(
|
||||
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
|
||||
|
||||
async def list_all_definitions(
|
||||
database: NestQuestDatabase,
|
||||
) -> list[CreatedQuestDefinition]:
|
||||
"""Return EVERY definition, active AND inactive (rich view).
|
||||
|
||||
Mirrors :func:`list_active_definitions` without the activity
|
||||
filter: ordered by rising definition id, each entry the same
|
||||
:class:`CreatedQuestDefinition` snapshot, read by the DAO inside one
|
||||
locked transaction.
|
||||
"""
|
||||
dao = QuestDefinitionsDao(database)
|
||||
snapshots = await dao.list_snapshots_all()
|
||||
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
|
||||
|
||||
async def list_definitions_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
|
||||
@@ -257,8 +257,7 @@ QUEST_DEFINITIONS_TABLE_SQL = """(
|
||||
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
|
||||
due_time TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
skip_on_away INTEGER NOT NULL DEFAULT 1 CHECK (skip_on_away IN (0, 1))
|
||||
created_at TEXT NOT NULL
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Explicit admin-only versus open service registry (Feature 09).
|
||||
|
||||
One mapping, one file: every registered ``nestquest`` service name is
|
||||
either ``open`` or ``admin_only``. The permission decorator (later
|
||||
Feature 09 tasks) reads this table; it does not take per-handler
|
||||
policy arguments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .const import (
|
||||
DOMAIN_SERVICES,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
|
||||
OPEN = "open"
|
||||
ADMIN_ONLY = "admin_only"
|
||||
|
||||
SERVICE_POLICY: dict[str, str] = {
|
||||
SERVICE_COMPLETE_QUEST: OPEN,
|
||||
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
|
||||
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
|
||||
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
|
||||
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
|
||||
SERVICE_REGENERATE: ADMIN_ONLY,
|
||||
}
|
||||
|
||||
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
|
||||
raise RuntimeError(
|
||||
"SERVICE_POLICY must name exactly the registered nestquest services, "
|
||||
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
|
||||
)
|
||||
@@ -26,17 +26,12 @@ later completed simply gains its completion event; history keeps both
|
||||
rows (append-only), and the sweep's later runs skip it via the
|
||||
no-completion-event rule.
|
||||
|
||||
The whole watermark read → query → build span runs inside the
|
||||
The whole watermark read → query → build → write span runs inside the
|
||||
per-database sweep lock (one lock per connection wrapper and running
|
||||
loop, the settings store's pattern): an overlapping startup sweep and
|
||||
a rollover sweep of the SAME process queue instead of interleaving.
|
||||
The announce decision itself is the DATABASE-level claim
|
||||
(:meth:`MetaStateDao.claim`), so two PROCESSES sharing one SQLite file
|
||||
(the integration's rollover sweep and the API service's scheduler)
|
||||
cannot both announce the same missed batch either: both may read the
|
||||
old watermark and build the events, but the single conditional upsert
|
||||
lets exactly one of them win the claim — the loser returns [] and
|
||||
announces nothing.
|
||||
a rollover sweep queue instead of interleaving, and the loser re-reads
|
||||
the watermark INSIDE the lock — sees the winner's date — and announces
|
||||
nothing twice.
|
||||
|
||||
The caller supplies ``today`` as a plain :class:`datetime.date`: the
|
||||
household-local calendar date, read from ``hass.config.time_zone`` by
|
||||
@@ -65,11 +60,11 @@ LOGGER = logging.getLogger(__name__)
|
||||
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
|
||||
|
||||
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
|
||||
#: the settings store's pattern: the watermark read → query → build
|
||||
#: span is one critical section, so an overlapping startup sweep and
|
||||
#: rollover sweep of the SAME process queue instead of interleaving.
|
||||
#: Same-process callers are fully serialised by it; the cross-process
|
||||
#: case is closed by the database-level claim, not by this lock.
|
||||
#: the settings store's pattern: the whole watermark read → query →
|
||||
#: build → write span is one critical section, so an overlapping
|
||||
#: startup sweep and rollover sweep queue instead of double-announcing.
|
||||
#: The loser re-reads the watermark INSIDE the lock, sees the winner's
|
||||
#: date, and fires nothing.
|
||||
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||
|
||||
|
||||
@@ -104,24 +99,15 @@ async def run_missed_sweep(
|
||||
the caller decides how to publish them and announces nothing on
|
||||
its own.
|
||||
|
||||
The watermark read → query → build span runs inside the
|
||||
per-database sweep lock, so overlapping callers of ONE process
|
||||
queue instead of interleaving. The announce decision is the
|
||||
DATABASE-level claim (:meth:`MetaStateDao.claim` — a single
|
||||
conditional upsert on the watermark, atomic across processes):
|
||||
every caller builds the events optimistically, then only the
|
||||
caller whose claim actually advanced the watermark returns them;
|
||||
a loser (another process already announced for ``today``) returns
|
||||
[] and announces nothing twice. Every event in one run shares one
|
||||
The whole watermark read → query → build → write span runs inside
|
||||
the per-database sweep lock: an overlapping startup sweep and a
|
||||
rollover sweep queue instead of interleaving, and the loser
|
||||
re-reads the watermark inside the lock — sees the winner's date —
|
||||
and announces nothing twice. Every event in one run shares one
|
||||
``occurred_at`` stamp (one run, one announcement moment), and the
|
||||
payload comes from the shared
|
||||
:func:`~.events._instance_payload` builder so the shape can never
|
||||
drift from the documented §3 contract.
|
||||
|
||||
Failure ordering: the watermark is advanced ONLY by the claim,
|
||||
AFTER the events are fully built — a failure before the claim
|
||||
leaves the watermark unadvanced, so a later run re-sweeps the same
|
||||
window and nothing is lost.
|
||||
"""
|
||||
today_iso = today.isoformat()
|
||||
dao = MetaStateDao(database)
|
||||
@@ -148,23 +134,10 @@ async def run_missed_sweep(
|
||||
),
|
||||
)
|
||||
)
|
||||
# The database-level claim gates the announcement: only the
|
||||
# caller that actually advanced the watermark may publish the
|
||||
# built events, so two processes racing one SQLite file cannot
|
||||
# both announce the same missed batch. A same-value loser's
|
||||
# claim matches zero rows and this run announces nothing.
|
||||
claimed = await dao.claim(SWEEP_WATERMARK_KEY, today_iso)
|
||||
if not claimed:
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s lost the watermark claim; "
|
||||
"announcing nothing",
|
||||
today_iso,
|
||||
)
|
||||
return []
|
||||
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s built %d event(s)",
|
||||
today_iso,
|
||||
len(events),
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package.
|
||||
|
||||
The children/admin DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_children` (extracted in
|
||||
task d0b691d8); this module re-exports it so existing
|
||||
``from .dao_children import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_children import * # noqa: F401,F403
|
||||
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package.
|
||||
|
||||
The instances/completion-events DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_instances`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .dao_instances import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_instances import * # noqa: F401,F403
|
||||
from .core.dao_instances import _resolve_today # noqa: F401
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package.
|
||||
|
||||
The meta-state DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_meta` (extracted in task
|
||||
d0b691d8); this module re-exports it so existing
|
||||
``from .dao_meta import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_meta import * # noqa: F401,F403
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package.
|
||||
|
||||
The presence DAOs live in
|
||||
:mod:`custom_components.nestquest.core.dao_presence` (extracted in
|
||||
task d0b691d8); this module re-exports them so existing
|
||||
``from .dao_presence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_presence import * # noqa: F401,F403
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_rules` for the integration package.
|
||||
|
||||
The schedule-rules/quest-definitions DAO lives in
|
||||
:mod:`custom_components.nestquest.core.dao_rules` (extracted in task
|
||||
d0b691d8); this module re-exports it so existing
|
||||
``from .dao_rules import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.dao_rules import * # noqa: F401,F403
|
||||
from .core.dao_rules import ( # noqa: F401
|
||||
_UNSET,
|
||||
_validate_date,
|
||||
_validate_time,
|
||||
_validate_window,
|
||||
)
|
||||
@@ -1,215 +0,0 @@
|
||||
"""Zero-config panel dashboard registration for NestQuest (Feature 20).
|
||||
|
||||
On setup the integration registers ONE storage-mode Lovelace dashboard
|
||||
whose CONFIG uses the bundle's ``custom:nestquest-party`` strategy (see
|
||||
``frontend/src/strategy.ts``): the views themselves are GENERATED at
|
||||
render time from the household roster, so the stored dashboard config
|
||||
carries nothing but the strategy reference.
|
||||
|
||||
Registration mirrors the REAL two-step sequence the lovelace
|
||||
integration itself uses when it creates the onboarding map dashboard
|
||||
(``homeassistant/components/lovelace/__init__.py``,
|
||||
``_create_map_dashboard``):
|
||||
|
||||
1. create the dashboard METADATA through the ``DashboardsCollection``
|
||||
(``hass.data["lovelace"]["dashboards_collection"]``) — url_path,
|
||||
title, icon, sidebar visibility. The strategy reference is NOT part
|
||||
of the create payload: the collection's create schema allows only
|
||||
the metadata fields, so a ``strategy`` key there fails the whole
|
||||
create (this was the silent no-op the first implementation shipped).
|
||||
2. save the dashboard CONFIG — ``{"strategy": ...}`` — through the
|
||||
per-url_path CONTENT manager that the collection's change listener
|
||||
registers under ``hass.data["lovelace"]["dashboards"][url_path]``
|
||||
(a ``LovelaceStorage``), exactly the second step HA performs.
|
||||
|
||||
Registration is IDEMPOTENT — a dashboard already under the stable
|
||||
``url_path`` (ours or a user's) skips BOTH steps and its stored config
|
||||
is never overwritten — and the Lovelace surfaces being unavailable
|
||||
(YAML mode, lovelace not loaded) is a logged warning, never a setup
|
||||
failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
DASHBOARD_ICON,
|
||||
DASHBOARD_STRATEGY_TYPE,
|
||||
DASHBOARD_TITLE,
|
||||
DASHBOARD_URL_PATH,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
# Single-flight guard for the check-then-create below: HA can set up
|
||||
# several config entries CONCURRENTLY, and two overlapping setups would
|
||||
# both pass the exists-check and race on the url_path create. The lock
|
||||
# is per hass instance (kept as an attribute on the hass object, since
|
||||
# hass.data is the integrations' shared registry the unload path
|
||||
# empties) rather than one module-level asyncio.Lock — a bare
|
||||
# module-level lock binds to whichever event loop first awaits it and
|
||||
# raises RuntimeError on any other loop, while the race it must prevent
|
||||
# only exists between setups running on the SAME hass anyway.
|
||||
_REGISTRATION_LOCK_ATTR = "_nestquest_dashboard_registration_lock"
|
||||
|
||||
|
||||
def _registration_lock(hass: HomeAssistant) -> asyncio.Lock:
|
||||
"""Return the per-hass registration lock, creating it on first use."""
|
||||
lock = getattr(hass, _REGISTRATION_LOCK_ATTR, None)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
setattr(hass, _REGISTRATION_LOCK_ATTR, lock)
|
||||
return lock
|
||||
|
||||
|
||||
def _lovelace_data(hass: HomeAssistant) -> dict | None:
|
||||
"""Return ``hass.data["lovelace"]`` when it is the real dict shape.
|
||||
|
||||
The lovelace integration stores a plain DICT of surfaces (``mode``,
|
||||
``dashboards``, ``dashboards_collection``, ``resources``,
|
||||
``yaml_dashboards``) under the ``lovelace`` key; anything else
|
||||
(missing key, unexpected object) means the surfaces below cannot be
|
||||
reached and registration must warn and continue.
|
||||
"""
|
||||
lovelace_data = hass.data.get("lovelace")
|
||||
if not isinstance(lovelace_data, dict):
|
||||
return None
|
||||
return lovelace_data
|
||||
|
||||
|
||||
def _dashboards_collection(hass: HomeAssistant):
|
||||
"""Return the Lovelace dashboards METADATA collection, or None.
|
||||
|
||||
The collection lives at ``hass.data["lovelace"]
|
||||
["dashboards_collection"]`` — NOT under ``["dashboards"]``, which
|
||||
maps url_path to the per-dashboard CONTENT managers
|
||||
(``LovelaceStorage``/``LovelaceYAML``). A missing key (lovelace
|
||||
not loaded) and a missing/None entry (YAML mode never sets the
|
||||
collection) both mean "cannot create storage dashboards here" —
|
||||
callers log and continue. The presence of the surface is verified
|
||||
by capability (``async_create_item`` callable), not by type, so no
|
||||
lovelace import is needed.
|
||||
"""
|
||||
lovelace_data = _lovelace_data(hass)
|
||||
if lovelace_data is None:
|
||||
return None
|
||||
collection = lovelace_data.get("dashboards_collection")
|
||||
if collection is None or not callable(
|
||||
getattr(collection, "async_create_item", None)
|
||||
):
|
||||
return None
|
||||
return collection
|
||||
|
||||
|
||||
def _dashboard_store(hass: HomeAssistant, url_path: str):
|
||||
"""Return a url_path's dashboard CONTENT manager, or None.
|
||||
|
||||
After a metadata create, the collection's change listener registers
|
||||
a ``LovelaceStorage`` for the new dashboard under
|
||||
``hass.data["lovelace"]["dashboards"][url_path]``; the dashboard
|
||||
CONFIG (the strategy reference) is saved through THAT manager —
|
||||
the second step of HA's own dashboard creation.
|
||||
"""
|
||||
lovelace_data = _lovelace_data(hass)
|
||||
if lovelace_data is None:
|
||||
return None
|
||||
dashboards = lovelace_data.get("dashboards")
|
||||
if not isinstance(dashboards, dict):
|
||||
return None
|
||||
store = dashboards.get(url_path)
|
||||
if store is None or not callable(getattr(store, "async_save", None)):
|
||||
return None
|
||||
return store
|
||||
|
||||
|
||||
def _item_url_path(item) -> str | None:
|
||||
"""Read a dashboard item's ``url_path`` through either access shape.
|
||||
|
||||
The collection hands back storage items as dicts while builtin
|
||||
dashboards come back as config objects; the url_path is reachable
|
||||
through ``item["url_path"]`` or ``item.url_path`` respectively.
|
||||
"""
|
||||
url_path = getattr(item, "url_path", None)
|
||||
if isinstance(url_path, str):
|
||||
return url_path
|
||||
get_url_path = getattr(item, "get", None)
|
||||
if callable(get_url_path):
|
||||
value = get_url_path("url_path")
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _existing_dashboard(items) -> bool:
|
||||
"""Whether a dashboard under the stable url_path already exists."""
|
||||
return any(_item_url_path(item) == DASHBOARD_URL_PATH for item in items)
|
||||
|
||||
|
||||
async def async_register_dashboard(hass: HomeAssistant) -> bool:
|
||||
"""Register the NestQuest panel dashboard exactly once.
|
||||
|
||||
Returns True when a dashboard under the stable ``url_path`` exists
|
||||
after the call (pre-existing or created here), False when the
|
||||
Lovelace surfaces were unavailable or refused. A dashboard already
|
||||
registered under the stable ``url_path`` — whether ours from a
|
||||
previous setup or a user's own — skips BOTH steps and is left
|
||||
COMPLETELY alone: its stored config is never overwritten, it is
|
||||
never deleted, never a duplicate. The strategy config is saved
|
||||
ONLY for the dashboard created in THIS call, whose fresh content
|
||||
store is empty, so the save cannot clobber anything.
|
||||
"""
|
||||
collection = _dashboards_collection(hass)
|
||||
if collection is None:
|
||||
_LOGGER.warning(
|
||||
"Lovelace dashboards collection unavailable; the NestQuest "
|
||||
"panel dashboard was not registered. Enable the Lovelace "
|
||||
"integration in storage mode or create a dashboard with "
|
||||
"strategy %s manually.",
|
||||
DASHBOARD_STRATEGY_TYPE,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
# Single-flight: two concurrent entry setups must not both pass
|
||||
# the exists-check and race on the url_path create.
|
||||
async with _registration_lock(hass):
|
||||
# async_items() is a SYNC @callback on the real collection
|
||||
# (helpers/collection.py ObservableCollection) — not awaited.
|
||||
if _existing_dashboard(collection.async_items()):
|
||||
return True
|
||||
await collection.async_create_item(
|
||||
{
|
||||
"url_path": DASHBOARD_URL_PATH,
|
||||
"title": DASHBOARD_TITLE,
|
||||
"icon": DASHBOARD_ICON,
|
||||
"show_in_sidebar": True,
|
||||
"allow_single_word": True,
|
||||
}
|
||||
)
|
||||
# Step two (mirroring HA's _create_map_dashboard): the
|
||||
# strategy CONFIG goes through the fresh dashboard's
|
||||
# content manager, not through the metadata collection.
|
||||
store = _dashboard_store(hass, DASHBOARD_URL_PATH)
|
||||
if store is None:
|
||||
_LOGGER.warning(
|
||||
"The NestQuest panel dashboard was registered but its "
|
||||
"Lovelace content manager never appeared; add the "
|
||||
"strategy %s to the dashboard manually.",
|
||||
DASHBOARD_STRATEGY_TYPE,
|
||||
)
|
||||
return False
|
||||
await store.async_save(
|
||||
{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
"Registering the NestQuest panel dashboard in the Lovelace "
|
||||
"dashboards collection failed; set it up manually with "
|
||||
"strategy %s.",
|
||||
DASHBOARD_STRATEGY_TYPE,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package.
|
||||
|
||||
The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task
|
||||
d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes
|
||||
an executor callable rather than a ``hass`` object. This module
|
||||
re-exports it and provides :func:`make_database`, a thin adapter that
|
||||
constructs a wrapper backed by ``hass.async_add_executor_job``, so the
|
||||
integration constructs the database the same way it always did.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .core.db import ExecutionResult, NestQuestDatabase
|
||||
|
||||
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def make_database(hass: HomeAssistant) -> NestQuestDatabase:
|
||||
"""Construct a :class:`NestQuestDatabase` wired to ``hass``'s executor.
|
||||
|
||||
Mirrors the pre-extraction ``NestQuestDatabase(hass)`` call: the
|
||||
wrapper's executor is ``hass.async_add_executor_job``, so every
|
||||
sqlite3 call runs on the HA executor thread and the event loop
|
||||
never blocks.
|
||||
"""
|
||||
return NestQuestDatabase(hass.async_add_executor_job)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Re-export / HA wiring of :mod:`custom_components.nestquest.core.events` for the integration package.
|
||||
|
||||
The transition-event payload construction lives in
|
||||
:mod:`custom_components.nestquest.core.events` (extracted in
|
||||
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
|
||||
shim keeps the integration's old ``fire_quest_completed`` /
|
||||
``fire_quest_uncompleted`` API (the complete/uncomplete service
|
||||
handlers call them with ``hass``), reads the HA-local timezone from
|
||||
``hass.config.time_zone`` to derive ``today``, asks the core builder
|
||||
for the events, and fires each on ``hass.bus`` with the documented
|
||||
payloads. The four blueprints keep working with identical payloads.
|
||||
|
||||
The completed event is fired BEFORE the day-complete rule evaluates:
|
||||
``fire_quest_completed`` asks
|
||||
:func:`build_quest_completed_event` for the completed event and fires
|
||||
it immediately, THEN reads the HA-local ``today``, asks
|
||||
:func:`build_child_day_complete_event` for the day-complete event, and
|
||||
fires it (if any) afterwards. This restores the pre-extraction
|
||||
ordering — a failure in the day-complete DB reads or the ZoneInfo
|
||||
lookup cannot suppress the completed event.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .core.db import NestQuestDatabase
|
||||
from .core.events import (
|
||||
build_child_day_complete_event,
|
||||
build_quest_completed_event,
|
||||
build_quest_uncompleted_events,
|
||||
)
|
||||
|
||||
__all__ = ["fire_quest_completed", "fire_quest_uncompleted"]
|
||||
|
||||
|
||||
async def fire_quest_completed(
|
||||
hass,
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
was_on_time: bool | None,
|
||||
) -> None:
|
||||
"""Fire ``nestquest_quest_completed`` and, when the completion
|
||||
cleared the child's whole day, ``nestquest_child_day_complete``.
|
||||
|
||||
Thin HA wiring over the HA-free
|
||||
:func:`custom_components.nestquest.core.events.build_quest_completed_event`
|
||||
and
|
||||
:func:`build_child_day_complete_event`: the completed event is
|
||||
built and fired FIRST (so a failure in the day-complete DB reads
|
||||
or the HA-local ``today`` lookup cannot suppress it), then the
|
||||
HA-local ``today`` (the day-complete rule's input) is read from
|
||||
``hass.config.time_zone`` and the day-complete builder is asked
|
||||
whether the child's whole day cleared. Identical payloads and
|
||||
day-complete rule to the pre-extraction implementation.
|
||||
"""
|
||||
completed = await build_quest_completed_event(
|
||||
database,
|
||||
instance_id,
|
||||
was_on_time=was_on_time,
|
||||
)
|
||||
hass.bus.async_fire(*completed)
|
||||
|
||||
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
day_complete = await build_child_day_complete_event(
|
||||
database, completed[1], today=today
|
||||
)
|
||||
if day_complete is not None:
|
||||
hass.bus.async_fire(*day_complete)
|
||||
|
||||
|
||||
async def fire_quest_uncompleted(
|
||||
hass, database: NestQuestDatabase, instance_id: int
|
||||
) -> None:
|
||||
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
|
||||
events = await build_quest_uncompleted_events(database, instance_id)
|
||||
for event_type, payload in events:
|
||||
hass.bus.async_fire(event_type, payload)
|
||||
@@ -7,13 +7,12 @@
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"frontend",
|
||||
"http",
|
||||
"lovelace"
|
||||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.6.0"
|
||||
"version": "0.5.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.materialize` for the integration package.
|
||||
|
||||
The materialization walk lives in
|
||||
:mod:`custom_components.nestquest.core.materialize` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .materialize import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.materialize import * # noqa: F401,F403
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package.
|
||||
|
||||
The versioned migration runner lives in
|
||||
:mod:`custom_components.nestquest.core.migrations`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .migrations import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.migrations import * # noqa: F401,F403
|
||||
@@ -1,15 +1,4 @@
|
||||
"""Options flow for NestQuest post-setup settings.
|
||||
|
||||
Since the DB removal (Feature 18) the integration is a thin client of
|
||||
the NestQuest API service: the settings that only served the local
|
||||
database (the generation horizon, the day rollover, the notification
|
||||
defaults, the admin allowlist) are gone — the API service owns those in
|
||||
its own settings store, and the automations take their behaviour from
|
||||
blueprint inputs. What remains is what the CLIENT itself consumes: the
|
||||
panel-plane API connection (base URL + token), the shared coordinator's
|
||||
refresh interval, the last-good staleness threshold, and the
|
||||
panel-facing idle timeout.
|
||||
"""
|
||||
"""Options flow for NestQuest post-setup settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
@@ -17,39 +6,61 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_API_BASE_URL,
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_PANEL_TOKEN,
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
from .api_client import is_valid_base_url
|
||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||
|
||||
MIN_HORIZON_DAYS = 1
|
||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
||||
|
||||
|
||||
def _async_validate(
|
||||
user_input: dict[str, Any],
|
||||
current: dict[str, Any] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Validate user input and return field-level errors instead of raising.
|
||||
def _parse_time(value: str) -> bool:
|
||||
"""Return True when value is a valid 24-hour HH:MM string."""
|
||||
return TIME_PATTERN.fullmatch(value) is not None
|
||||
|
||||
``current`` carries the stored values (entry options → entry data →
|
||||
defaults) so a raw submit that sets one field but omits a sibling it
|
||||
is validated against (the staleness threshold vs. the update
|
||||
interval) can still check the effective pair; raw submits omitting
|
||||
BOTH keys keep their stored values untouched.
|
||||
"""
|
||||
|
||||
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate user input and return field-level errors instead of raising."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
horizon_days = user_input.get(CONF_HORIZON_DAYS)
|
||||
if (
|
||||
isinstance(horizon_days, bool)
|
||||
or not isinstance(horizon_days, int)
|
||||
or horizon_days < MIN_HORIZON_DAYS
|
||||
):
|
||||
errors[CONF_HORIZON_DAYS] = "invalid"
|
||||
|
||||
day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME)
|
||||
if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time):
|
||||
errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time"
|
||||
|
||||
panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT)
|
||||
if (
|
||||
isinstance(panel_idle_timeout, bool)
|
||||
@@ -70,127 +81,270 @@ def _async_validate(
|
||||
):
|
||||
errors[CONF_UPDATE_INTERVAL] = "invalid"
|
||||
|
||||
# The last-good staleness threshold (Feature 18) is OPTIONAL in raw
|
||||
# submits the same way: a missing key keeps the current value. It
|
||||
# must be a positive int, and at least the EFFECTIVE update interval
|
||||
# (the one this submit carries, else the stored or default one) — a
|
||||
# threshold shorter than the poll cadence could never serve even one
|
||||
# cached snapshot.
|
||||
staleness = user_input.get(CONF_SNAPSHOT_STALENESS)
|
||||
if staleness is not None and (
|
||||
isinstance(staleness, bool)
|
||||
or not isinstance(staleness, int)
|
||||
or staleness < 1
|
||||
# The notification section (Feature 11) is OPTIONAL in raw submits
|
||||
# the same way: a missing key keeps the current value. The three
|
||||
# times are strict HH:MM; the notify target is a non-empty HA
|
||||
# service name; the toggles are real booleans.
|
||||
for time_key in (
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
):
|
||||
errors[CONF_SNAPSHOT_STALENESS] = "invalid"
|
||||
elif staleness is not None and not errors.get(CONF_UPDATE_INTERVAL):
|
||||
stored_interval = (current or {}).get(CONF_UPDATE_INTERVAL)
|
||||
effective_interval = update_interval
|
||||
if effective_interval is None and (
|
||||
isinstance(stored_interval, int)
|
||||
and not isinstance(stored_interval, bool)
|
||||
and stored_interval >= MIN_UPDATE_INTERVAL
|
||||
time_value = user_input.get(time_key)
|
||||
if time_value is not None and (
|
||||
not isinstance(time_value, str) or not _parse_time(time_value)
|
||||
):
|
||||
effective_interval = stored_interval
|
||||
if effective_interval is None:
|
||||
effective_interval = DEFAULT_UPDATE_INTERVAL
|
||||
if staleness < effective_interval:
|
||||
errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness"
|
||||
|
||||
# The panel-plane API connection (Feature 18) is OPTIONAL in raw
|
||||
# submits the same way: a missing key keeps the current value. The
|
||||
# base URL must be a full http(s) URL with a host — the same check
|
||||
# the client's constructor enforces, so a malformed URL fails at
|
||||
# the form, not at the first request. The token may be EMPTY (the
|
||||
# API connection is simply not configured yet) but a non-empty one
|
||||
# must be pasted unpadded: a padded or whitespace-only value is a
|
||||
# clipboard mistake that would fail the token check at request
|
||||
# time.
|
||||
api_base_url = user_input.get(CONF_API_BASE_URL)
|
||||
if api_base_url is not None and not is_valid_base_url(api_base_url):
|
||||
errors[CONF_API_BASE_URL] = "invalid_url"
|
||||
panel_token = user_input.get(CONF_PANEL_TOKEN)
|
||||
if panel_token is not None and (
|
||||
not isinstance(panel_token, str)
|
||||
or panel_token != panel_token.strip()
|
||||
errors[time_key] = "invalid_time"
|
||||
notify_target = user_input.get(CONF_NOTIFY_TARGET)
|
||||
if notify_target is not None and not isinstance(notify_target, str):
|
||||
errors[CONF_NOTIFY_TARGET] = "invalid"
|
||||
elif isinstance(notify_target, str) and notify_target != (
|
||||
notify_target.strip()
|
||||
):
|
||||
errors[CONF_PANEL_TOKEN] = "invalid"
|
||||
# An EMPTY target is valid — it means "no notifications
|
||||
# configured yet" and is the shipped default; a value that
|
||||
# differs from its own trim (padded or whitespace-only) is
|
||||
# malformed and rejected.
|
||||
errors[CONF_NOTIFY_TARGET] = "invalid"
|
||||
for toggle_key in (
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
):
|
||||
toggle_value = user_input.get(toggle_key)
|
||||
if toggle_value is not None and not isinstance(toggle_value, bool):
|
||||
errors[toggle_key] = "invalid"
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _build_schema(current: dict[str, Any]) -> vol.Schema:
|
||||
def _strict_bool(value: object) -> bool:
|
||||
"""Require a real bool; voluptuous ``bool`` would coerce strings."""
|
||||
if not isinstance(value, bool):
|
||||
raise vol.Invalid(f"expected a boolean, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _build_schema(
|
||||
current: dict[str, Any],
|
||||
*,
|
||||
admin_choices: dict[str, str] | None = None,
|
||||
admin_default: list[str] | None = None,
|
||||
) -> vol.Schema:
|
||||
"""Build the form schema, pre-filled with the current values.
|
||||
|
||||
The API connection (Feature 18) carries the base URL the API client
|
||||
talks to and the panel service token it presents as the Bearer
|
||||
credential. Schema-validated submits always carry every field (the
|
||||
defaults pre-fill from the stored values); raw callers omitting one
|
||||
keep its stored value (see _async_validate).
|
||||
The admin picker is a multi-select of EXISTING Home Assistant users
|
||||
(never a free-text field — the allowlist stores user IDs); its
|
||||
default is the current database allowlist, so an untouched submit
|
||||
re-saves exactly who is already allowed. The notification section
|
||||
(Feature 11) carries the household's notify target, the three
|
||||
automation times, and four enable toggles — defaults from
|
||||
:mod:`~.const`, never a hard-coded personal target.
|
||||
"""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
default=current[CONF_PANEL_IDLE_TIMEOUT],
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||
): int,
|
||||
# The last-good staleness threshold (Feature 18): how long
|
||||
# the coordinator keeps serving the cached snapshot after
|
||||
# the API stops answering.
|
||||
vol.Required(
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
default=current[CONF_SNAPSHOT_STALENESS],
|
||||
): int,
|
||||
# Panel-plane API connection (Feature 18): the base URL the
|
||||
# API client talks to and the panel service token it
|
||||
# presents as the Bearer credential.
|
||||
vol.Required(
|
||||
CONF_API_BASE_URL, default=current[CONF_API_BASE_URL]
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN]
|
||||
): str,
|
||||
}
|
||||
)
|
||||
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,
|
||||
vol.Required(
|
||||
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
default=current[CONF_MORNING_SUMMARY_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
default=current[CONF_AFTERNOON_REMINDER_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
default=current[CONF_END_OF_DAY_REPORT_TIME],
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
default=current[CONF_MORNING_SUMMARY_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
default=current[CONF_AFTERNOON_REMINDER_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
default=current[CONF_END_OF_DAY_REPORT_ENABLED],
|
||||
): _strict_bool,
|
||||
vol.Required(
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
default=current[CONF_CELEBRATION_ENABLED],
|
||||
): _strict_bool,
|
||||
}
|
||||
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):
|
||||
"""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(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the NestQuest options."""
|
||||
admin_choices = await self._async_admin_choices()
|
||||
admin_default = await self._async_current_admin_ids()
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
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 = _async_validate(user_input, self._current_values())
|
||||
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:
|
||||
return self.async_show_form(
|
||||
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,
|
||||
)
|
||||
|
||||
# Every remaining key is stored the fall-back-to-current way, so
|
||||
# raw submits that predate a section keep that section's stored
|
||||
# values.
|
||||
# Apply the picker's selection to the allowlist when the
|
||||
# runtime database is reachable; the selection is always
|
||||
# stored on the entry as the disaster-recovery copy.
|
||||
database = self._async_database()
|
||||
if database is not None and admin_ids_present:
|
||||
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],
|
||||
CONF_UPDATE_INTERVAL: user_input.get(
|
||||
CONF_UPDATE_INTERVAL,
|
||||
self._current_values()[CONF_UPDATE_INTERVAL],
|
||||
),
|
||||
}
|
||||
# The notification section (Feature 11) stores the household's
|
||||
# choices the same fall-back-to-current way, so raw submits
|
||||
# that predate the section keep their stored values.
|
||||
current = self._current_values()
|
||||
data: dict[str, Any] = {}
|
||||
for key in (
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
CONF_SNAPSHOT_STALENESS,
|
||||
CONF_API_BASE_URL,
|
||||
CONF_PANEL_TOKEN,
|
||||
for notification_key in (
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
):
|
||||
data[key] = user_input.get(key, current[key])
|
||||
data[notification_key] = user_input.get(
|
||||
notification_key, current[notification_key]
|
||||
)
|
||||
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]:
|
||||
@@ -199,11 +353,18 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
entry_data = getattr(self.config_entry, "data", None) or {}
|
||||
values: dict[str, Any] = {}
|
||||
for key, default in (
|
||||
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
|
||||
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
||||
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
||||
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
||||
(CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS),
|
||||
(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
|
||||
(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
||||
(CONF_NOTIFY_TARGET, ""),
|
||||
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
|
||||
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
|
||||
(CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME),
|
||||
(CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
|
||||
):
|
||||
if key in options:
|
||||
values[key] = options[key]
|
||||
@@ -211,4 +372,4 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
values[key] = entry_data[key]
|
||||
else:
|
||||
values[key] = default
|
||||
return values
|
||||
return values
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Permission gate for NestQuest services (Feature 09).
|
||||
|
||||
A wrapper factory, applied to EVERY registered service at registration
|
||||
time (no handler is exempt), reads ``call.context.user_id``, consults
|
||||
the :data:`~.service_policy.SERVICE_POLICY` registry and
|
||||
:func:`~.admin_allowlist.is_admin`, and raises
|
||||
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
|
||||
operation called by a non-admin — including a call with NO user
|
||||
context, which fails closed. ``complete_quest`` (policy ``open``)
|
||||
proceeds regardless of the allowlist.
|
||||
|
||||
Logging (later Feature 09 tasks): every denied admin-only call logs a
|
||||
warning and every successful one logs a debug line, both carrying the
|
||||
service, the user id (or ``none``), and a UTC timestamp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
|
||||
from .admin_allowlist import is_admin
|
||||
from .service_policy import ADMIN_ONLY
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||
|
||||
|
||||
def _context_user_id(call: Any) -> str | None:
|
||||
context = getattr(call, "context", None)
|
||||
user_id = getattr(context, "user_id", None) if context is not None else None
|
||||
if isinstance(user_id, str) and user_id:
|
||||
return user_id
|
||||
return None
|
||||
|
||||
|
||||
def _stamp() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def permission_gate(
|
||||
service: str,
|
||||
policy: str,
|
||||
database_of: Callable[[Any], Any],
|
||||
) -> Callable[[ServiceHandler], ServiceHandler]:
|
||||
"""Wrap ``handler`` with the Feature 09 permission check.
|
||||
|
||||
``database_of`` resolves the live database from the call-time
|
||||
runtime (the same ``find_runtime`` the handler uses), so the gate
|
||||
never opens its own connection. The verdict is fail-closed:
|
||||
absent allowlist row, unknown id, no user context — all not admin.
|
||||
"""
|
||||
|
||||
def _wrap(handler: ServiceHandler) -> ServiceHandler:
|
||||
if policy != ADMIN_ONLY:
|
||||
return handler
|
||||
|
||||
async def _gated(call: Any) -> None:
|
||||
user_id = _context_user_id(call)
|
||||
allowed = user_id is not None and await is_admin(
|
||||
database_of(call), user_id
|
||||
)
|
||||
if not allowed:
|
||||
LOGGER.warning(
|
||||
"NestQuest denied %s: caller user_id=%s at %s",
|
||||
service,
|
||||
user_id if user_id is not None else "none",
|
||||
_stamp(),
|
||||
)
|
||||
raise Unauthorized(
|
||||
f"User '{user_id if user_id is not None else 'none'}' "
|
||||
f"is not allowed to call nestquest.{service}"
|
||||
)
|
||||
LOGGER.debug(
|
||||
"NestQuest allowed %s: caller user_id=%s at %s",
|
||||
service,
|
||||
user_id,
|
||||
_stamp(),
|
||||
)
|
||||
await handler(call)
|
||||
|
||||
return _gated
|
||||
|
||||
return _wrap
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.presence` for the integration package.
|
||||
|
||||
The presence and custody model lives in
|
||||
:mod:`custom_components.nestquest.core.presence` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .presence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.presence import * # noqa: F401,F403
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.presence_management` for the integration package.
|
||||
|
||||
The presence-write business layer lives in
|
||||
:mod:`custom_components.nestquest.core.presence_management` (added for
|
||||
task e4a9d31b); this module re-exports it so the integration's service
|
||||
handlers call the ONE implementation the API admin plane shares.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.presence_management import * # noqa: F401,F403
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.quest_definitions` for the integration package.
|
||||
|
||||
The quest definitions business layer lives in
|
||||
:mod:`custom_components.nestquest.core.quest_definitions` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .quest_definitions import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.quest_definitions import * # noqa: F401,F403
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package.
|
||||
|
||||
The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence`
|
||||
(extracted in task d0b691d8); this module re-exports it so existing
|
||||
``from .recurrence import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.recurrence import * # noqa: F401,F403
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package.
|
||||
|
||||
The declarative schema DDL lives in
|
||||
:mod:`custom_components.nestquest.core.schema` (extracted in
|
||||
task d0b691d8); this module re-exports it so existing
|
||||
``from .schema import ...`` references inside the integration keep
|
||||
working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.schema import * # noqa: F401,F403
|
||||
@@ -32,7 +32,6 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
@@ -42,21 +41,13 @@ from .coordinator import (
|
||||
)
|
||||
from .core.snapshot import instance_payload
|
||||
|
||||
#: HA's state machine refuses states longer than 255
|
||||
#: Home Assistant's state machine refuses states longer than 255
|
||||
#: characters (its hard recorder/state limit). These sensors' states
|
||||
#: are numeric, but the guard is applied uniformly: a payload-bearing
|
||||
#: sensor can never crash the state write, and later string-state
|
||||
#: sensors reuse the same helper.
|
||||
MAX_STATE_LENGTH = 255
|
||||
|
||||
#: Entity-id length cap the roster applies to HA's slugify: HA's
|
||||
#: slugifier itself does NOT truncate, but the entity registry derives
|
||||
#: entity ids as ``<domain>.<slug>`` and truncates the full entity_id at
|
||||
#: MAX_LENGTH_STATE_ENTITY_ID (64), so for this module's ``sensor.``
|
||||
#: entities the slugged object id alone may not exceed
|
||||
#: 64 - len("sensor.").
|
||||
_MAX_SLUG_LENGTH = 64 - len("sensor.")
|
||||
|
||||
|
||||
def _state_within_limit(state: Any) -> Any:
|
||||
"""Return ``state`` unchanged, refusing strings over HA's limit.
|
||||
@@ -107,51 +98,6 @@ def _next_open_quest(
|
||||
)
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""Slugify a display name the way Home Assistant derives entity ids.
|
||||
|
||||
Runs ``homeassistant.util.slugify`` — the real slugifier HA runs
|
||||
entity names through, which transliterates every script to ASCII
|
||||
through unidecode (so non-Latin names survive: ``京子`` slugs to
|
||||
``jing_zi``) before folding to lowercase and turning every run of
|
||||
non-alphanumerics into one underscore, answering HA's ``unknown``
|
||||
sentinel when a name slugs to nothing — then caps the result at
|
||||
the object-id length the entity registry enforces (the registry
|
||||
truncates the full ``sensor.`` + slug entity_id at 64 characters,
|
||||
so the slug alone is capped like this; HA's slugify does not
|
||||
truncate by itself).
|
||||
"""
|
||||
return slugify(text)[:_MAX_SLUG_LENGTH]
|
||||
|
||||
|
||||
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
|
||||
"""The ordered child roster the panel cards discover children from.
|
||||
|
||||
One entry per ACTIVE child in the snapshot's order (the API's
|
||||
sort_order, then id): ``child_id``, ``name``, and the ``slug`` the
|
||||
entity-id derivation produces for the child's name — the piece the
|
||||
cards need to build ``sensor.nestquest_<slug>_...`` ids. Duplicate
|
||||
display names get the registry's ``_2``/``_3`` suffix in snapshot
|
||||
order, matching how HA resolves the collision at registration. The
|
||||
rollups' ``children`` attribute (per-child counts) is unchanged and
|
||||
carries a different shape on purpose.
|
||||
"""
|
||||
slugs: dict[str, int] = {}
|
||||
roster: list[dict[str, Any]] = []
|
||||
for child in children:
|
||||
base = _slugify(child.child_name)
|
||||
taken = slugs.get(base, 0) + 1
|
||||
slugs[base] = taken
|
||||
roster.append(
|
||||
{
|
||||
"child_id": child.child_id,
|
||||
"name": child.child_name,
|
||||
"slug": base if taken == 1 else f"{base}_{taken}",
|
||||
}
|
||||
)
|
||||
return roster
|
||||
|
||||
|
||||
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
|
||||
"""Base for the per-child day sensors.
|
||||
|
||||
@@ -410,12 +356,7 @@ class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
|
||||
|
||||
|
||||
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
||||
"""Total quests due today across active children.
|
||||
|
||||
Also publishes ``child_roster`` (see :func:`_child_roster`): the
|
||||
ordered child identity list the panel cards read to discover the
|
||||
board's children when the card config carries no ``child_order``.
|
||||
"""
|
||||
"""Total quests due today across active children."""
|
||||
|
||||
_attr_name = "NestQuest household quests due today"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
@@ -439,8 +380,7 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
||||
"children": {
|
||||
str(child.child_id): child.due_today
|
||||
for child in data.children
|
||||
},
|
||||
"child_roster": _child_roster(data.children),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package.
|
||||
|
||||
The service policy registry lives in
|
||||
:mod:`custom_components.nestquest.core.service_policy` (extracted in
|
||||
task 510c1f78); this module re-exports it so existing
|
||||
``from .service_policy import ...`` references inside the integration
|
||||
keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.service_policy import * # noqa: F401,F403
|
||||
@@ -1,36 +1,61 @@
|
||||
"""The domain-global ``nestquest.complete_quest`` service (Feature 18).
|
||||
"""Domain-global NestQuest service schemas and handlers (Feature 09).
|
||||
|
||||
Registration is domain-scoped and happens once (see
|
||||
:func:`async_register_services`). Since Feature 18 the integration is
|
||||
a client of the NestQuest API service, so ``complete_quest`` proxies
|
||||
to the API's panel complete route through the entry's shared API
|
||||
client instead of writing any database. The former HA admin services
|
||||
(uncomplete, quest-definition and presence management, CSV export,
|
||||
child management, regenerate) are removed: the admin surface is the
|
||||
PWA and the API service's admin routes, so the only HA service left is
|
||||
the panel completion path.
|
||||
:func:`async_register_services`); handlers resolve a live database at
|
||||
call time through the injected ``find_runtime`` callable. Permission
|
||||
checks are Feature 09 later tasks — this module only validates payload
|
||||
shape and forwards to the Feature 03/06/08 business layers (the
|
||||
presence writes included, through :mod:`.presence_management` — the
|
||||
ONE implementation the API admin plane shares).
|
||||
``export_history_csv`` is registered with a schema but raises until
|
||||
Feature 13. ``regenerate`` is supplied by the caller so
|
||||
the Feature 07 handler stays the one in ``__init__``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .api_client import NestQuestApiError
|
||||
from . import children as children_layer
|
||||
from . import completion as completion_layer
|
||||
from . import presence_management as presence_management_layer
|
||||
from . import quest_definitions as quest_definitions_layer
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
DOMAIN_SERVICES,
|
||||
QUEST_WINDOWS,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
from .events import fire_quest_completed, fire_quest_uncompleted
|
||||
from .permissions import permission_gate
|
||||
from .recurrence import ScheduleRule
|
||||
from .service_policy import SERVICE_POLICY
|
||||
|
||||
FindRuntime = Callable[[HomeAssistant], Any]
|
||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||
|
||||
ACTOR_PANEL = "panel"
|
||||
ACTOR_USER = "user"
|
||||
MANAGE_CHILD_CREATE = "create"
|
||||
MANAGE_CHILD_EDIT = "edit"
|
||||
MANAGE_CHILD_SET_ACTIVE = "set_active"
|
||||
MANAGE_CHILD_REORDER = "reorder"
|
||||
|
||||
|
||||
def _strict_int(value: object) -> int:
|
||||
@@ -40,6 +65,82 @@ def _strict_int(value: object) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _strict_bool(value: object) -> bool:
|
||||
"""Reject ints and other truthy values; require a real bool."""
|
||||
if type(value) is not bool:
|
||||
raise vol.Invalid(f"expected bool, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _weekday_list(value: object) -> list[int]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
|
||||
return [_strict_int(item) for item in value]
|
||||
|
||||
|
||||
def _presence_pattern(value: object) -> dict[int, list[int]]:
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
|
||||
pattern: dict[int, list[int]] = {}
|
||||
for week, days in value.items():
|
||||
if isinstance(week, str):
|
||||
try:
|
||||
week_index = int(week)
|
||||
except ValueError as err:
|
||||
raise vol.Invalid(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
) from err
|
||||
if str(week_index) != week:
|
||||
raise vol.Invalid(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
)
|
||||
else:
|
||||
week_index = _strict_int(week)
|
||||
pattern[week_index] = _weekday_list(days)
|
||||
return pattern
|
||||
|
||||
|
||||
def _window_entry(value: object) -> str | tuple[str, str | None]:
|
||||
if isinstance(value, str):
|
||||
if value not in QUEST_WINDOWS:
|
||||
raise vol.Invalid(f"unknown window {value!r}")
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
parsed = vol.Schema(
|
||||
{
|
||||
vol.Required("window"): vol.In(QUEST_WINDOWS),
|
||||
vol.Optional("due_time"): str,
|
||||
}
|
||||
)(value)
|
||||
due_time = parsed.get("due_time")
|
||||
if due_time is None:
|
||||
return parsed["window"]
|
||||
return (parsed["window"], due_time)
|
||||
raise vol.Invalid(
|
||||
f"windows entry must be a window name or object, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _windows(value: object) -> list[str | tuple[str, str | None]]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"windows must be a list, got {value!r}")
|
||||
if not value:
|
||||
raise vol.Invalid("windows must not be empty")
|
||||
return [_window_entry(entry) for entry in value]
|
||||
|
||||
|
||||
def _int_list(value: object) -> list[int]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"expected a list of ints, got {value!r}")
|
||||
return [_strict_int(item) for item in value]
|
||||
|
||||
|
||||
def _rule_dict(value: object) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid(f"rule must be a dict, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||
{
|
||||
vol.Required("instance_id"): _strict_int,
|
||||
@@ -49,22 +150,102 @@ SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
|
||||
|
||||
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
|
||||
{
|
||||
vol.Required("title"): str,
|
||||
vol.Required("rule"): _rule_dict,
|
||||
vol.Required("assignee_child_ids"): vol.All(
|
||||
_int_list, vol.Length(min=1)
|
||||
),
|
||||
vol.Required("windows"): _windows,
|
||||
vol.Optional("description"): str,
|
||||
vol.Optional("icon"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
|
||||
{
|
||||
vol.Required("definition_id"): _strict_int,
|
||||
vol.Optional("title"): str,
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("rule"): _rule_dict,
|
||||
vol.Optional("windows"): _windows,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
|
||||
{
|
||||
vol.Required("definition_id"): _strict_int,
|
||||
vol.Required("is_active"): _strict_bool,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
|
||||
{
|
||||
vol.Required("child_id"): _strict_int,
|
||||
vol.Required("cycle_length_weeks"): _strict_int,
|
||||
vol.Required("anchor_date"): str,
|
||||
vol.Required("pattern"): _presence_pattern,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
|
||||
{
|
||||
vol.Required("child_id"): _strict_int,
|
||||
vol.Required("start_date"): str,
|
||||
vol.Required("end_date"): str,
|
||||
vol.Required("is_present"): _strict_bool,
|
||||
vol.Optional("note"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
|
||||
{
|
||||
vol.Required("override_id"): _strict_int,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
|
||||
|
||||
SCHEMA_MANAGE_CHILD = vol.Schema(
|
||||
{
|
||||
vol.Required("action"): vol.In(
|
||||
[
|
||||
MANAGE_CHILD_CREATE,
|
||||
MANAGE_CHILD_EDIT,
|
||||
MANAGE_CHILD_SET_ACTIVE,
|
||||
MANAGE_CHILD_REORDER,
|
||||
]
|
||||
),
|
||||
vol.Optional("child_id"): _strict_int,
|
||||
vol.Optional("display_name"): str,
|
||||
vol.Optional("colour"): str,
|
||||
vol.Optional("avatar_ref"): str,
|
||||
vol.Optional("sort_order"): _strict_int,
|
||||
vol.Optional("is_active"): _strict_bool,
|
||||
vol.Optional("ordered_ids"): _int_list,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
||||
runtime = find_runtime(hass)
|
||||
if runtime is None:
|
||||
raise HomeAssistantError(
|
||||
"NestQuest is not loaded; cannot run this service"
|
||||
"NestQuest has no live database; cannot run this service"
|
||||
)
|
||||
return runtime
|
||||
|
||||
|
||||
async def _refresh_entities(runtime: Any) -> None:
|
||||
"""Force an immediate coordinator refresh after the service call.
|
||||
"""Force an immediate coordinator refresh after a service mutation.
|
||||
|
||||
Only the SERVICE path calls this — the scheduled materialize and
|
||||
Only the SERVICE paths call this — the scheduled materialize and
|
||||
rollover internals are direct function calls and never widen this
|
||||
gate — so the entities reflect a completion the moment the service
|
||||
gate — so entities reflect a completion the moment the service
|
||||
call returns instead of waiting for the poll interval.
|
||||
"""
|
||||
coordinator = getattr(runtime, "coordinator", None)
|
||||
@@ -72,6 +253,12 @@ async def _refresh_entities(runtime: Any) -> None:
|
||||
await coordinator.async_refresh()
|
||||
|
||||
|
||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
return {"today": today, "horizon_days": runtime.settings.horizon_days}
|
||||
|
||||
|
||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||
actor = call.data["actor"]
|
||||
if actor == ACTOR_PANEL:
|
||||
@@ -114,50 +301,223 @@ def _complete_quest(
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
instance_id = call.data["instance_id"]
|
||||
# The panel contract (decision 9): a tap completes as the
|
||||
# tapped child profile, the only actor shape the API's panel
|
||||
# route accepts. The payload schema is unchanged — the panel
|
||||
# cards call it with instance_id, actor=panel, actor_child_id.
|
||||
actor = _actor_kwargs(call)
|
||||
if actor["actor_source"] != ACTOR_PANEL:
|
||||
raise HomeAssistantError(
|
||||
"nestquest.complete_quest completes through the API's "
|
||||
"panel route as the tapped child profile; actor must "
|
||||
"be 'panel' with actor_child_id"
|
||||
)
|
||||
# The entry's ONE shared API client (built at setup): the
|
||||
# coordinator's snapshot poll, the SSE subscription, and this
|
||||
# proxy all ride the same instance. An entry with no panel
|
||||
# token runs the unconfigured stub, which has no completion
|
||||
# surface — fail fast with a clear, token-free message.
|
||||
complete_instance = getattr(
|
||||
getattr(runtime.coordinator, "api_client", None),
|
||||
"complete_instance",
|
||||
None,
|
||||
# ``appended`` is decided under the same lock as the write, so
|
||||
# a concurrent duplicate call (a double tap) can not produce a
|
||||
# second transition event: the loser appends nothing.
|
||||
result = await completion_layer.complete_instance(
|
||||
runtime.database,
|
||||
instance_id,
|
||||
**_actor_kwargs(call),
|
||||
)
|
||||
if complete_instance is None:
|
||||
raise HomeAssistantError(
|
||||
"NestQuest API is not configured; cannot complete a "
|
||||
"quest"
|
||||
if result.appended:
|
||||
await fire_quest_completed(
|
||||
hass,
|
||||
runtime.database,
|
||||
instance_id,
|
||||
was_on_time=result.was_on_time,
|
||||
)
|
||||
try:
|
||||
await complete_instance(instance_id, actor["actor_child_id"])
|
||||
except NestQuestApiError as err:
|
||||
# The typed API failure surfaces as a service error the
|
||||
# caller sees — never swallowed, never retried here. The
|
||||
# typed message names method, path, and status only; the
|
||||
# panel token never appears in it.
|
||||
await _refresh_entities(runtime)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _uncomplete_quest(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
instance_id = call.data["instance_id"]
|
||||
result = await completion_layer.uncomplete_instance(
|
||||
runtime.database,
|
||||
instance_id,
|
||||
**_actor_kwargs(call),
|
||||
)
|
||||
if result.appended:
|
||||
await fire_quest_uncompleted(
|
||||
hass, runtime.database, instance_id
|
||||
)
|
||||
await _refresh_entities(runtime)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _create_quest_definition(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
database = _require_runtime(hass, find_runtime).database
|
||||
data = call.data
|
||||
await quest_definitions_layer.create_quest_definition(
|
||||
database,
|
||||
data["title"],
|
||||
ScheduleRule.from_dict(data["rule"]),
|
||||
data["assignee_child_ids"],
|
||||
data["windows"],
|
||||
description=data.get("description"),
|
||||
icon=data.get("icon"),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _update_quest_definition(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
|
||||
if "title" in data:
|
||||
kwargs["title"] = data["title"]
|
||||
if "description" in data:
|
||||
kwargs["description"] = data["description"]
|
||||
if "icon" in data:
|
||||
kwargs["icon"] = data["icon"]
|
||||
if "rule" in data:
|
||||
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
|
||||
if "windows" in data:
|
||||
kwargs["windows"] = data["windows"]
|
||||
await quest_definitions_layer.edit_quest_definition(
|
||||
runtime.database, data["definition_id"], **kwargs
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _set_quest_definition_active(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
await quest_definitions_layer.set_quest_definition_active(
|
||||
runtime.database,
|
||||
call.data["definition_id"],
|
||||
call.data["is_active"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _set_presence_pattern(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
await presence_management_layer.set_presence_schedule(
|
||||
runtime.database,
|
||||
data["child_id"],
|
||||
data["cycle_length_weeks"],
|
||||
data["anchor_date"],
|
||||
data["pattern"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _create_presence_override(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
await presence_management_layer.create_presence_override(
|
||||
runtime.database,
|
||||
data["child_id"],
|
||||
data["start_date"],
|
||||
data["end_date"],
|
||||
data["is_present"],
|
||||
note=data.get("note"),
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _delete_presence_override(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
await presence_management_layer.delete_presence_override(
|
||||
runtime.database,
|
||||
call.data["override_id"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
async def _export_history_csv(_call: Any) -> None:
|
||||
raise HomeAssistantError(
|
||||
"CSV history export is not implemented until Feature 13"
|
||||
)
|
||||
|
||||
|
||||
def _manage_child(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
database = _require_runtime(hass, find_runtime).database
|
||||
data = call.data
|
||||
action = data["action"]
|
||||
if action == MANAGE_CHILD_CREATE:
|
||||
display_name = data.get("display_name")
|
||||
if display_name is None:
|
||||
raise HomeAssistantError(
|
||||
"display_name is required when action is create"
|
||||
)
|
||||
kwargs: dict[str, Any] = {}
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.create_child(
|
||||
database, display_name, **kwargs
|
||||
)
|
||||
return
|
||||
if action == MANAGE_CHILD_EDIT:
|
||||
child_id = data.get("child_id")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is edit"
|
||||
)
|
||||
kwargs = {}
|
||||
if "display_name" in data:
|
||||
kwargs["display_name"] = data["display_name"]
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.edit_child(database, child_id, **kwargs)
|
||||
return
|
||||
if action == MANAGE_CHILD_SET_ACTIVE:
|
||||
child_id = data.get("child_id")
|
||||
is_active = data.get("is_active")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is set_active"
|
||||
)
|
||||
if is_active is None:
|
||||
raise HomeAssistantError(
|
||||
"is_active is required when action is set_active"
|
||||
)
|
||||
await children_layer.set_child_active(
|
||||
database, child_id, is_active
|
||||
)
|
||||
return
|
||||
ordered_ids = data.get("ordered_ids")
|
||||
if ordered_ids is None:
|
||||
raise HomeAssistantError(
|
||||
f"NestQuest quest completion failed: {err}"
|
||||
) from err
|
||||
# NO local completion write and NO local event: the API service
|
||||
# emits the transition on its SSE stream and the integration's
|
||||
# subscription re-fires it on the bus — firing it here too
|
||||
# would double-fire. The immediate refresh keeps the old
|
||||
# behaviour where the sensors reflect the completion the
|
||||
# moment the service call returns instead of waiting for the
|
||||
# poll interval.
|
||||
await _refresh_entities(runtime)
|
||||
"ordered_ids is required when action is reorder"
|
||||
)
|
||||
await children_layer.reorder_children(database, ordered_ids)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
@@ -166,25 +526,84 @@ def async_register_services(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
find_runtime: FindRuntime,
|
||||
regenerate_handler: ServiceHandler,
|
||||
) -> None:
|
||||
"""Register the panel completion service once at domain scope.
|
||||
"""Register every canonical service once at domain scope.
|
||||
|
||||
The one remaining HA service is the panel completion path; every
|
||||
admin operation moved to the API service's admin routes, so there
|
||||
is no permission gate to apply here.
|
||||
Every service — regenerate included — passes through the Feature
|
||||
09 permission gate before its handler runs; the gate reads
|
||||
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
|
||||
registered exempt from the policy.
|
||||
"""
|
||||
if hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST):
|
||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||
return
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
_complete_quest(hass, find_runtime),
|
||||
schema=SCHEMA_COMPLETE_QUEST,
|
||||
)
|
||||
gate = permission_gate
|
||||
specs: list[tuple[str, ServiceHandler, object]] = [
|
||||
(SERVICE_REGENERATE, regenerate_handler, None),
|
||||
(
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
_complete_quest(hass, find_runtime),
|
||||
SCHEMA_COMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
_uncomplete_quest(hass, find_runtime),
|
||||
SCHEMA_UNCOMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
_create_quest_definition(hass, find_runtime),
|
||||
SCHEMA_CREATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
_update_quest_definition(hass, find_runtime),
|
||||
SCHEMA_UPDATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
_set_quest_definition_active(hass, find_runtime),
|
||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
_set_presence_pattern(hass, find_runtime),
|
||||
SCHEMA_SET_PRESENCE_PATTERN,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
_create_presence_override(hass, find_runtime),
|
||||
SCHEMA_CREATE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
_delete_presence_override(hass, find_runtime),
|
||||
SCHEMA_DELETE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
_export_history_csv,
|
||||
SCHEMA_EXPORT_HISTORY_CSV,
|
||||
),
|
||||
(
|
||||
SERVICE_MANAGE_CHILD,
|
||||
_manage_child(hass, find_runtime),
|
||||
SCHEMA_MANAGE_CHILD,
|
||||
),
|
||||
]
|
||||
|
||||
def _database_of(_call: Any) -> Any:
|
||||
return _require_runtime(hass, find_runtime).database
|
||||
|
||||
for service, handler, schema in specs:
|
||||
gated = gate(
|
||||
service, SERVICE_POLICY[service], _database_of
|
||||
)(handler)
|
||||
hass.services.async_register(DOMAIN, service, gated, schema=schema)
|
||||
|
||||
|
||||
def async_deregister_services(hass: HomeAssistant) -> None:
|
||||
"""Remove every canonical service when the last entry unloads."""
|
||||
for service in DOMAIN_SERVICES:
|
||||
if hass.services.has_service(DOMAIN, service):
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# Service declarations for the NestQuest integration (Home Assistant
|
||||
# services.yaml metadata). Services are domain-scoped: nested under the
|
||||
# "nestquest" domain, not repeated per config entry. Since Feature 18
|
||||
# only the panel completion service remains — every admin operation
|
||||
# moved to the PWA and the API service's admin routes.
|
||||
# "nestquest" domain, not repeated per config entry.
|
||||
|
||||
regenerate:
|
||||
name: Regenerate quest instances
|
||||
description: >-
|
||||
Regenerate every future quest instance for the household over the rolling
|
||||
horizon, using the same idempotent materialization as the daily rollover
|
||||
and the startup backfill. Safe to call at any time: a re-run refreshes the
|
||||
same rows instead of duplicating them, and completed instances are never
|
||||
touched.
|
||||
|
||||
complete_quest:
|
||||
name: Complete quest
|
||||
@@ -36,4 +43,284 @@ complete_quest:
|
||||
description: Home Assistant user id when actor is user and no call context is present.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
text:
|
||||
|
||||
uncomplete_quest:
|
||||
name: Uncomplete quest
|
||||
description: Undo a quest instance completion.
|
||||
fields:
|
||||
instance_id:
|
||||
name: Instance ID
|
||||
description: The quest instance to uncomplete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor:
|
||||
name: Actor
|
||||
description: Who is undoing the completion (panel or user).
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- panel
|
||||
- user
|
||||
actor_child_id:
|
||||
name: Actor child ID
|
||||
description: The tapped child profile. Required when actor is panel.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor_user_id:
|
||||
name: Actor user ID
|
||||
description: Home Assistant user id when actor is user and no call context is present.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
create_quest_definition:
|
||||
name: Create quest definition
|
||||
description: Create a quest definition with its schedule rule, assignees and windows.
|
||||
fields:
|
||||
title:
|
||||
name: Title
|
||||
description: Display title for the quest.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
rule:
|
||||
name: Rule
|
||||
description: Recurrence rule as a mapping (rule_type plus shape fields).
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
assignee_child_ids:
|
||||
name: Assignee child IDs
|
||||
description: Child ids this definition is assigned to.
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
windows:
|
||||
name: Windows
|
||||
description: Day windows (morning, afternoon, evening), optionally with a due time.
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
description:
|
||||
name: Description
|
||||
description: Optional longer description.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
name: Icon
|
||||
description: Optional icon name.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
update_quest_definition:
|
||||
name: Update quest definition
|
||||
description: Edit a quest definition's title, description, icon, rule or windows.
|
||||
fields:
|
||||
definition_id:
|
||||
name: Definition ID
|
||||
description: The quest definition to update.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
title:
|
||||
name: Title
|
||||
description: Replacement display title.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
description:
|
||||
name: Description
|
||||
description: Replacement description, or null to clear.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
name: Icon
|
||||
description: Replacement icon, or null to clear.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
rule:
|
||||
name: Rule
|
||||
description: Replacement recurrence rule as a mapping.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
windows:
|
||||
name: Windows
|
||||
description: Replacement day windows.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
|
||||
set_quest_definition_active:
|
||||
name: Set quest definition active
|
||||
description: Deactivate or reactivate a quest definition.
|
||||
fields:
|
||||
definition_id:
|
||||
name: Definition ID
|
||||
description: The quest definition to update.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
is_active:
|
||||
name: Active
|
||||
description: Whether the definition should generate future instances.
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
set_presence_pattern:
|
||||
name: Set presence pattern
|
||||
description: Upsert a child's repeating presence schedule.
|
||||
fields:
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: The child whose schedule is being set.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
cycle_length_weeks:
|
||||
name: Cycle length weeks
|
||||
description: Number of weeks in the repeating cycle (1 to 4).
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
min: 1
|
||||
max: 4
|
||||
anchor_date:
|
||||
name: Anchor date
|
||||
description: Cycle anchor as a YYYY-MM-DD date.
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
pattern:
|
||||
name: Pattern
|
||||
description: Mapping of week index to present weekday ints (Monday is 0).
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
create_presence_override:
|
||||
name: Create presence override
|
||||
description: Create a date-range presence override for a child.
|
||||
fields:
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: The child the override applies to.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
start_date:
|
||||
name: Start date
|
||||
description: Inclusive start date (YYYY-MM-DD).
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
end_date:
|
||||
name: End date
|
||||
description: Inclusive end date (YYYY-MM-DD).
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
is_present:
|
||||
name: Present
|
||||
description: Whether the child is present for this range.
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
note:
|
||||
name: Note
|
||||
description: Optional note stored with the override.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
delete_presence_override:
|
||||
name: Delete presence override
|
||||
description: Delete a presence override by id.
|
||||
fields:
|
||||
override_id:
|
||||
name: Override ID
|
||||
description: The override to delete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
|
||||
export_history_csv:
|
||||
name: Export history CSV
|
||||
description: Export completion history as CSV. Not implemented until Feature 13.
|
||||
|
||||
manage_child:
|
||||
name: Manage child
|
||||
description: Create, edit, activate or reorder child profiles.
|
||||
fields:
|
||||
action:
|
||||
name: Action
|
||||
description: create, edit, set_active, or reorder.
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- create
|
||||
- edit
|
||||
- set_active
|
||||
- reorder
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: Required for edit and set_active.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
display_name:
|
||||
name: Display name
|
||||
description: Required for create; optional for edit.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
colour:
|
||||
name: Colour
|
||||
description: Optional colour string.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
avatar_ref:
|
||||
name: Avatar
|
||||
description: Optional avatar reference.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
sort_order:
|
||||
name: Sort order
|
||||
description: Optional panel sort order.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
is_active:
|
||||
name: Active
|
||||
description: Required for set_active.
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
ordered_ids:
|
||||
name: Ordered IDs
|
||||
description: Complete permutation of child ids. Required for reorder.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
"""HA-bus subscription over the API service's panel SSE stream.
|
||||
|
||||
The NestQuest API service (Feature 16) publishes every quest
|
||||
transition on ``GET /api/v1/panel/events`` as Server-Sent Events
|
||||
frames. This module is the integration-side subscriber: ONE
|
||||
background task consumes :meth:`~.api_client.NestQuestApiClient
|
||||
.stream_events` and re-fires each frame on ``hass.bus`` with the SAME
|
||||
event type and payload fields the local transition path
|
||||
(:mod:`~.events`) emits — ``nestquest_quest_completed``,
|
||||
``nestquest_quest_uncompleted``, ``nestquest_quest_missed`` and
|
||||
``nestquest_child_day_complete`` — so the four blueprints fire
|
||||
identically whether a completion happens through the integration's own
|
||||
service or through the API service's panel route.
|
||||
|
||||
Failure handling: a failed stream (API down, network blip, non-2xx, a
|
||||
dropped connection) is retried FOREVER with a bounded exponential
|
||||
backoff — :data:`RECONNECT_MIN_SECONDS` after the first failure,
|
||||
doubling up to :data:`RECONNECT_MAX_SECONDS` — so a long API outage
|
||||
neither pins the event loop nor spams the log, and a frame received on
|
||||
a healthy stream resets the backoff. Nothing escapes the task: every
|
||||
failure is logged with its reason and the loop reconnects; a
|
||||
:class:`asyncio.CancelledError` (from :meth:`stop` or integration
|
||||
unload) is the only thing that ends it, and it stops cleanly — no
|
||||
frame fires after :meth:`stop` returns and the response is closed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from .const import LOGGER
|
||||
|
||||
__all__ = [
|
||||
"NestQuestEventStream",
|
||||
"RECONNECT_MAX_SECONDS",
|
||||
"RECONNECT_MIN_SECONDS",
|
||||
]
|
||||
|
||||
#: Delay before the FIRST reconnect attempt after a stream failure.
|
||||
#: One failed frame must not turn into a hot reconnect loop.
|
||||
RECONNECT_MIN_SECONDS = 5.0
|
||||
|
||||
#: Ceiling for the exponential reconnect backoff: a long API outage
|
||||
#: retries at a calm fixed cadence instead of growing without bound.
|
||||
RECONNECT_MAX_SECONDS = 60.0
|
||||
|
||||
|
||||
class NestQuestEventStream:
|
||||
"""Consume the panel SSE stream in the background, fire the HA bus.
|
||||
|
||||
ONE instance per config entry, built in integration setup with the
|
||||
SAME API client the coordinator polls (the client is stateless —
|
||||
one shared session, one connection pool). ``start()`` schedules
|
||||
the background task; ``stop()`` cancels and awaits it, so unload
|
||||
never leaks the task and no frame fires after teardown. The task
|
||||
itself is failure-proof: any exception from the stream (typed
|
||||
:class:`~.api_client.NestQuestApiError` or anything else) is
|
||||
logged and retried after the current backoff, never allowed to
|
||||
escape the task.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: Any,
|
||||
client: Any,
|
||||
*,
|
||||
reconnect_min_seconds: float = RECONNECT_MIN_SECONDS,
|
||||
reconnect_max_seconds: float = RECONNECT_MAX_SECONDS,
|
||||
) -> None:
|
||||
"""Store the hass bus target, the API client, and the backoff.
|
||||
|
||||
The client is anything exposing ``stream_events()`` (the real
|
||||
:class:`~.api_client.NestQuestApiClient` in production, a stub
|
||||
in tests). The backoff bounds must be positive and the minimum
|
||||
must not exceed the maximum — a misconfiguration should fail
|
||||
loudly at construction, not quietly reconnect at delay 0.
|
||||
"""
|
||||
if (
|
||||
not isinstance(reconnect_min_seconds, (int, float))
|
||||
or isinstance(reconnect_min_seconds, bool)
|
||||
or reconnect_min_seconds <= 0
|
||||
or reconnect_min_seconds > reconnect_max_seconds
|
||||
):
|
||||
raise ValueError(
|
||||
"reconnect_min_seconds must be a positive number no "
|
||||
f"greater than reconnect_max_seconds, got "
|
||||
f"{reconnect_min_seconds!r}"
|
||||
)
|
||||
if (
|
||||
not isinstance(reconnect_max_seconds, (int, float))
|
||||
or isinstance(reconnect_max_seconds, bool)
|
||||
or reconnect_max_seconds <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"reconnect_max_seconds must be a positive number, got "
|
||||
f"{reconnect_max_seconds!r}"
|
||||
)
|
||||
self._hass = hass
|
||||
self._client = client
|
||||
self._min_backoff = float(reconnect_min_seconds)
|
||||
self._max_backoff = float(reconnect_max_seconds)
|
||||
self._backoff = float(reconnect_min_seconds)
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
@property
|
||||
def task(self) -> asyncio.Task | None:
|
||||
"""The background task, or ``None`` when never started/stopped."""
|
||||
return self._task
|
||||
|
||||
def start(self) -> None:
|
||||
"""Schedule the subscription task (idempotent while it runs)."""
|
||||
if self._task is not None and not self._task.done():
|
||||
return
|
||||
self._backoff = self._min_backoff
|
||||
self._task = asyncio.create_task(
|
||||
self._run(), name="nestquest-sse-subscription"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel and await the subscription task.
|
||||
|
||||
Returns only after the task has actually finished, so unload
|
||||
can rely on: no further frame is fired after ``stop()``, and
|
||||
the underlying stream response has been closed. Safe to call
|
||||
when the task was never started or already stopped.
|
||||
"""
|
||||
task, self._task = self._task, None
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
# The task's own cancellation surfaces here — expected and
|
||||
# swallowed; a cancelling the CALLER would re-raise out of
|
||||
# the surrounding await on its own.
|
||||
pass
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""Consume stream frames forever, reconnecting on failure."""
|
||||
while True:
|
||||
try:
|
||||
async for event_type, payload in self._client.stream_events():
|
||||
# A frame arrived on a healthy stream: the next
|
||||
# failure reconnects quickly again.
|
||||
self._backoff = self._min_backoff
|
||||
try:
|
||||
self._hass.bus.async_fire(event_type, dict(payload))
|
||||
except Exception as err:
|
||||
# Firing one frame must never tear down a
|
||||
# healthy connection: log and keep consuming.
|
||||
LOGGER.error(
|
||||
"NestQuest panel event %s could not be fired "
|
||||
"on the Home Assistant event bus: %s",
|
||||
event_type,
|
||||
err,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Unload / shutdown: stop cleanly, never converted.
|
||||
raise
|
||||
except Exception as err:
|
||||
delay = self._backoff
|
||||
self._backoff = min(
|
||||
self._backoff * 2, self._max_backoff
|
||||
)
|
||||
LOGGER.warning(
|
||||
"NestQuest panel event stream failed (%s); "
|
||||
"reconnecting in %.1f s",
|
||||
err,
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package.
|
||||
|
||||
The path resolver lives in
|
||||
:mod:`custom_components.nestquest.core.store` (extracted in task
|
||||
d0b691d8) and is Home-Assistant-free:
|
||||
:func:`custom_components.nestquest.core.store.async_get_db_path`
|
||||
takes the full database path. This module re-exports it and provides
|
||||
the original ``async_get_db_path(hass)`` signature so the integration
|
||||
resolves the path from ``hass.config.path`` exactly as before.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .core.const import SQLITE_DB_FILENAME
|
||||
from .core.store import async_get_db_path as _core_async_get_db_path
|
||||
from .core.store import _ensure_dir
|
||||
|
||||
__all__ = ["async_get_db_path", "_ensure_dir"]
|
||||
|
||||
|
||||
async def async_get_db_path(hass: HomeAssistant) -> Path:
|
||||
"""Return the absolute path to the NestQuest SQLite database file.
|
||||
|
||||
Resolves the database file from ``hass.config.path`` and delegates to
|
||||
:func:`custom_components.nestquest.core.store.async_get_db_path`,
|
||||
which ensures the parent directory exists off the event loop via
|
||||
:func:`asyncio.to_thread`.
|
||||
"""
|
||||
return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME))
|
||||
@@ -18,19 +18,20 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NestQuest Settings",
|
||||
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
|
||||
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
|
||||
"data": {
|
||||
"horizon_days": "Generation horizon (days)",
|
||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
||||
"api_base_url": "NestQuest API base URL (http://host:port)",
|
||||
"panel_token": "Panel service token"
|
||||
"admin_user_ids": "Admin users (parents)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Invalid value.",
|
||||
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
|
||||
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""The nightly missed-quest sweep (Feature 11) — the Home Assistant shim.
|
||||
|
||||
The sweep's policy is the HA-free core's :mod:`~.core.sweep`: the
|
||||
watermark read → ``list_missed_unswept`` query → payload build →
|
||||
watermark span, all inside the per-database sweep lock, shared with
|
||||
the API plane. This module is the integration's thin adapter over it:
|
||||
at the configured day-rollover time (the same HA-local listener that
|
||||
re-materializes the rolling horizon) and once at setup it calls the
|
||||
core's :func:`~.core.sweep.run_missed_sweep` and fires each returned
|
||||
event on ``hass.bus`` — ONE ``nestquest_quest_missed`` per past-due
|
||||
open instance with the documented payload. The core never touches
|
||||
Home Assistant; the firing lives here.
|
||||
|
||||
Idempotency is the core's watermark: after a run, ``nestquest_meta_state``
|
||||
records the HA-local date the sweep ran, and the next run only sweeps
|
||||
instances due in ``[watermark, today)`` — a second run the same night
|
||||
fires nothing new, and a run after days of downtime sweeps exactly the
|
||||
accumulated window once. An instance swept as missed that is later
|
||||
completed simply gains its completion event; the sweep is read-only
|
||||
over the domain tables (``missed`` is derived state, never an
|
||||
event_type — Feature 08's contract).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .core.sweep import (
|
||||
SWEEP_WATERMARK_KEY,
|
||||
run_missed_sweep as _run_core_missed_sweep,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["SWEEP_WATERMARK_KEY", "run_missed_sweep"]
|
||||
|
||||
|
||||
async def run_missed_sweep(
|
||||
hass,
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> int:
|
||||
"""Fire one missed event per unswept past-due open instance.
|
||||
|
||||
``today`` optionally pins the HA-local date (threaded like the
|
||||
materialization and completion layers); when omitted it is read
|
||||
from ``hass.config.time_zone``. Returns the number of events
|
||||
fired. Fires nothing and returns 0 when the watermark already
|
||||
covers ``today`` (a same-night rerun) — the watermark read, the
|
||||
query, the payload build and the watermark write all live in the
|
||||
core's locked
|
||||
:func:`~.core.sweep.run_missed_sweep`; this shim only fires each
|
||||
returned event on the bus and counts them.
|
||||
"""
|
||||
today_date = (
|
||||
today
|
||||
if today is not None
|
||||
else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
)
|
||||
events = await _run_core_missed_sweep(database, today=today_date)
|
||||
for event_type, payload in events:
|
||||
hass.bus.async_fire(event_type, payload)
|
||||
fired = len(events)
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s fired %d event(s)",
|
||||
today_date.isoformat(),
|
||||
fired,
|
||||
)
|
||||
return fired
|
||||
@@ -18,19 +18,20 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NestQuest Settings",
|
||||
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
|
||||
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
|
||||
"data": {
|
||||
"horizon_days": "Generation horizon (days)",
|
||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
||||
"api_base_url": "NestQuest API base URL (http://host:port)",
|
||||
"panel_token": "Panel service token"
|
||||
"admin_user_ids": "Admin users (parents)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Invalid value.",
|
||||
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
|
||||
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
[Unit]
|
||||
Description=NestQuest OpenCode controller
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/mnt/ai-storage/projects/NestQuest
|
||||
ExecStartPre=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller --check
|
||||
ExecStart=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller
|
||||
Restart=on-failure
|
||||
RestartSec=60s
|
||||
RestartPreventExitStatus=77
|
||||
TimeoutStartSec=0
|
||||
KillMode=control-group
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
description: Run the NestQuest orchestrator now in this OpenCode terminal
|
||||
agent: terminal-orchestrator
|
||||
---
|
||||
|
||||
Run the NestQuest project queue now in this foreground OpenCode session.
|
||||
Read the current CTXD prompt and Maestro state, but use the FOREGROUND
|
||||
TERMINAL RUNTIME instead of the service runtime. Reconcile repository and
|
||||
pull-request state, then execute CONTROLLER_TICK repeatedly until a global
|
||||
terminal condition, explicit user stop, genuine global block, or host-forced
|
||||
yield. Do not start or wait for the systemd controller.
|
||||
@@ -1,203 +0,0 @@
|
||||
# NestQuest Architecture
|
||||
|
||||
Status: approved direction (2026-09-21). This document supersedes the
|
||||
single-process design implied by Features 01–14, in which the Home Assistant
|
||||
integration owned the database and the parent admin surface was planned as a
|
||||
Lovelace card inside Home Assistant.
|
||||
|
||||
## 1. Why this changed
|
||||
|
||||
The original design put everything inside the Home Assistant integration: the
|
||||
SQLite database, the business logic, the kid panel cards, and (planned) the
|
||||
parent admin card. Three problems surfaced:
|
||||
|
||||
1. **The panel needed hand configuration.** Standing up the kid panel required
|
||||
hand-writing card YAML (`child_order`, `quest_log_path`, `board_path`) and
|
||||
hand-creating a Lovelace dashboard. A fresh install rendered nothing.
|
||||
2. **The admin surface fought Home Assistant.** The admin design
|
||||
(`design/ADMIN-SPEC.md`) is drawn at 390×844 — it is a phone app. Trapping it
|
||||
inside a Lovelace card meant fighting HA's UI constraints for a mobile-first
|
||||
experience.
|
||||
3. **Data was locked to one process.** A standalone admin web app cannot reach a
|
||||
SQLite file inside Home Assistant's config directory.
|
||||
|
||||
The re-imagined design keeps the panel in Home Assistant (TouchHub loads an HA
|
||||
dashboard), moves data ownership to a standalone API service, and promotes the
|
||||
admin surface to a standalone mobile-first PWA.
|
||||
|
||||
## 2. Target architecture
|
||||
|
||||
```
|
||||
DAKboard TouchHub
|
||||
│ (loads an HA dashboard URL)
|
||||
▼
|
||||
HA box API box
|
||||
┌───────────────────────────────┐ ┌───────────────────────────────────┐
|
||||
│ Lovelace panel dashboard │ │ reverse proxy (TLS) │
|
||||
│ ├ nestquest-party-board-card │ service │ ├ /api/v1/panel/* LAN-only │
|
||||
│ └ nestquest-quest-log-card │ token │ └ /api/v1/admin/* public │
|
||||
│ │ ─────────▶ │ FastAPI (imports core/) │
|
||||
│ NestQuest integration │ │ └ SQLite (owned here) │
|
||||
│ (thin client, no database) │ ◀───────── │ Authentik (OIDC) │
|
||||
│ sensors + HA bus events │ SSE └───────────────────────────────────┘
|
||||
└───────────────────────────────┘ ▲
|
||||
PWA ───────────┘
|
||||
https://nestquest.cubecraftlabs.com
|
||||
```
|
||||
|
||||
One owner of the data (the API). Two clients (the HA integration, the PWA).
|
||||
|
||||
## 3. Data ownership
|
||||
|
||||
**The API service is the only writer.** The HA integration never opens the
|
||||
database; it reads a snapshot and posts completions. This removes the
|
||||
two-writer problem entirely.
|
||||
|
||||
- **Storage:** SQLite on the API box. Single household, trivial concurrency,
|
||||
and the existing schema, migrations, and DAO tests are already SQLite. Postgres
|
||||
buys nothing at this scale.
|
||||
- **Schema:** unchanged. The cutover from the current HA-hosted database is a
|
||||
file copy — same schema, same migration version.
|
||||
- **Backups:** continuous replication (e.g. Litestream) plus a proven restore.
|
||||
A configured-but-untested backup is not a backup.
|
||||
|
||||
## 4. Authentication: two planes
|
||||
|
||||
The kid panel and the admin PWA have opposite trust levels, so they use
|
||||
separate credentials and separate network exposure.
|
||||
|
||||
### Panel plane (machine)
|
||||
|
||||
- **Credential:** a static service token.
|
||||
- **Exposure:** `/api/v1/panel/*` is reachable **only from the LAN**.
|
||||
- **Grants:** read the snapshot, and the single open write — complete a quest as
|
||||
`actor: panel` with the tapped `actor_child_id`.
|
||||
- **Refused:** admin routes reject the service token outright, not merely as a
|
||||
non-admin. The internet cannot reach these routes at all.
|
||||
|
||||
This preserves the original fail-closed boundary: the kiosk can complete a
|
||||
quest and nothing else.
|
||||
|
||||
### Admin plane (human)
|
||||
|
||||
- **Credential:** an Authentik OIDC JWT (authorization code + PKCE).
|
||||
- **Exposure:** `/api/v1/admin/*` is internet-facing behind Authentik.
|
||||
- **Grants:** everything a parent can do — definitions, assignment, presence,
|
||||
children, un-complete, history, export, settings, regenerate.
|
||||
- **Gate:** the API validates the JWT (issuer, audience, JWKS signature, expiry)
|
||||
and requires membership in the `nestquest-admins` group. The Authentik group
|
||||
is the single source of truth for "who is a parent", replacing the
|
||||
integration-side admin allowlist.
|
||||
|
||||
## 5. Components
|
||||
|
||||
### `core/` — domain library
|
||||
|
||||
The extracted domain layer: schema, migrations, database access, DAOs, the pure
|
||||
recurrence and presence engines, materialization, completion, event payload
|
||||
construction, and the children/definitions/allowlist business layers. **No
|
||||
Home Assistant imports.** Consumed by both the API and (during transition) the
|
||||
integration. This is where the ~1500 existing tests live after extraction.
|
||||
|
||||
### `api/` — NestQuest API
|
||||
|
||||
A FastAPI service importing `core/`. Owns the database. Serves the panel and
|
||||
admin planes, validates Authentik tokens, and publishes a transition event
|
||||
stream (SSE) that the integration consumes. No business logic in route handlers.
|
||||
|
||||
### `custom_components/nestquest/` — HA integration (client)
|
||||
|
||||
A thin client. Polls the snapshot and builds the existing sensors; subscribes to
|
||||
the SSE stream and re-fires the four HA bus events; proxies the panel completion.
|
||||
Holds a last-good snapshot cache so a brief API outage does not blank the panel.
|
||||
Owns no database and no business logic.
|
||||
|
||||
### `panel/` — Lovelace cards and dashboard strategy
|
||||
|
||||
The two kid-facing Lit cards plus a dashboard strategy that generates the panel
|
||||
views at render time. Cards auto-discover children, so no `child_order` is
|
||||
required; the strategy computes navigation paths, so `quest_log_path` and
|
||||
`board_path` are not required.
|
||||
|
||||
### `admin/` — Admin PWA
|
||||
|
||||
A React progressive web app implementing `design/ADMIN-SPEC.md`, authenticated
|
||||
via Authentik OIDC, installable on a phone. The only admin surface.
|
||||
|
||||
## 6. Zero-configuration panel
|
||||
|
||||
A fresh install should show the panel with no YAML:
|
||||
|
||||
1. **Auto-discovery.** The party-board card reads an ordered child list from a
|
||||
household entity attribute instead of a configured `child_order`.
|
||||
2. **Dashboard strategy.** A Lovelace strategy generates a party-board view and
|
||||
one quest-log view per child, wiring navigation between them, so paths are
|
||||
computed rather than configured. Explicit configuration still wins if a user
|
||||
supplies it.
|
||||
3. **Auto-registration.** The integration registers the dashboard on first
|
||||
setup, so there is no manual dashboard step.
|
||||
|
||||
## 7. Home Assistant surface preserved
|
||||
|
||||
The re-imagining must not break what already works:
|
||||
|
||||
- **Sensors:** the per-child and household sensor entity_ids and the per-child
|
||||
instance attribute payload shape are unchanged. The panel cards and the four
|
||||
blueprints depend on them.
|
||||
- **Events:** the four HA bus events (`nestquest_quest_completed`,
|
||||
`nestquest_quest_uncompleted`, `nestquest_quest_missed`,
|
||||
`nestquest_child_day_complete`) keep firing with their documented payloads.
|
||||
They are now driven by the API's transition stream rather than by local
|
||||
service handlers.
|
||||
- **Blueprints:** the morning summary, afternoon reminder, end-of-day report, and
|
||||
day-complete celebration continue to work with no change.
|
||||
|
||||
## 8. Events without an HA token on the API box
|
||||
|
||||
The API publishes transitions on an SSE stream; the integration subscribes and
|
||||
re-fires the HA bus events locally. This deliberately avoids storing a Home
|
||||
Assistant long-lived token on the API box, which would grant full HA access.
|
||||
|
||||
## 9. Migration phases
|
||||
|
||||
Mapped to the Maestro features:
|
||||
|
||||
| Phase | Feature | Outcome |
|
||||
|---|---|---|
|
||||
| 1 | 15 — Domain Core Extraction | `core/` runs with no HA imports; one implementation of the rules |
|
||||
| 2 | 16 — NestQuest API Service | Panel and admin planes served with two-plane auth |
|
||||
| 3 | 17 — API Deployment and Identity | TLS, Authentik, backups, network separation |
|
||||
| 4 | 18 — HA Integration as API Client | Integration becomes a client; entity/event surface preserved |
|
||||
| 5 | 19 — Admin PWA | Mobile-first React PWA replaces the admin card |
|
||||
| 6 | 20 — Zero-Config Panel Dashboard | Panel installs itself; navigation and animations match the mockups |
|
||||
|
||||
Cutover: copy the existing SQLite file to the API box (same schema).
|
||||
|
||||
## 10. Locked decisions
|
||||
|
||||
- **API on its own box** — separate from Home Assistant.
|
||||
- **Authentik** (`https://auth.cubecraftlabs.com`) for admin identity; a
|
||||
`nestquest-admins` group is the admin source of truth.
|
||||
- **Single household** — no tenant column, no multi-household auth.
|
||||
- **React** for the admin PWA.
|
||||
- **Drop the HA admin services** — the PWA is the only admin surface. The panel
|
||||
completion path stays.
|
||||
- **SQLite on the API box** initially; Postgres only if multi-household ever
|
||||
becomes real.
|
||||
|
||||
## 11. Preserved vs. changed
|
||||
|
||||
**Preserved:** the tested Python domain layer, the panel card designs and
|
||||
tokens, the sensor and event contract, the completion model, the append-only
|
||||
event log, the panel's fail-closed "complete only" boundary.
|
||||
|
||||
**Changed:** data ownership moves to the API; the integration becomes a client;
|
||||
the admin card becomes a PWA; panel cards become config-free with a
|
||||
strategy-generated dashboard.
|
||||
|
||||
**New:** the API service, Authentik-gated admin auth, the PWA, and a
|
||||
last-good-snapshot cache in the integration.
|
||||
|
||||
**Cost:** two new deployables (the API and the PWA) and one new box. Accepted
|
||||
knowingly in exchange for a self-installing panel, an admin surface that is not
|
||||
fighting HA's UI, and a data layer with real auth in front of it.
|
||||
Generated
-522
@@ -9,197 +9,10 @@
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz",
|
||||
"integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.4.0",
|
||||
"@csstools/css-color-parser": "^4.2.3",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.1",
|
||||
"lru-cache": "^11.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz",
|
||||
"integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.1.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
|
||||
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
|
||||
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
|
||||
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.1",
|
||||
"@csstools/css-calc": "^3.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
|
||||
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
|
||||
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -642,24 +455,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
|
||||
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@lit-labs/ssr-dom-shim": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
|
||||
@@ -1097,79 +892,6 @@
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls/node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -1245,66 +967,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
|
||||
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz",
|
||||
"integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^7.0.0",
|
||||
"@asamuzakjp/dom-selector": "^9.2.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^7.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.10.2",
|
||||
"w3c-xmlserializer": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.1.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.2.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lit": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
|
||||
@@ -1336,23 +998,6 @@
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
|
||||
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
@@ -1372,19 +1017,6 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1434,26 +1066,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.63.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
|
||||
@@ -1500,19 +1112,6 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1540,52 +1139,6 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.14",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz",
|
||||
"integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.14"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.14",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz",
|
||||
"integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1600,16 +1153,6 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.11.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz",
|
||||
"integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
@@ -1684,71 +1227,6 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
|
||||
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "17.1.2",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz",
|
||||
"integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
|
||||
@@ -143,16 +143,6 @@ function asNumber(value: unknown, fallback: number): number {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** The slug a discovered roster entry carries, or "" when the entry is
|
||||
* not an object with a non-empty slug string. */
|
||||
function asRosterSlug(entry: unknown): string {
|
||||
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return "";
|
||||
}
|
||||
const slug = (entry as Record<string, unknown>).slug;
|
||||
return typeof slug === "string" ? slug.trim() : "";
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
@@ -902,34 +892,15 @@ export class NestQuestPartyBoardCard extends LitElement {
|
||||
return [];
|
||||
}
|
||||
const order = this._config.child_order;
|
||||
if (Array.isArray(order)) {
|
||||
return order
|
||||
.filter(
|
||||
(entry): entry is string =>
|
||||
typeof entry === "string" && entry.trim().length > 0
|
||||
)
|
||||
.map((entry) => entry.trim());
|
||||
}
|
||||
return this._discoveredChildSlugs();
|
||||
}
|
||||
|
||||
/** Zero-config discovery (Feature 20): the ordered child roster the
|
||||
* integration publishes on the household rollup
|
||||
* (custom_components/nestquest/sensor.py — ``child_roster``, the
|
||||
* snapshot's sort_order, then id), each entry carrying the slug the
|
||||
* per-child entity ids are built from. Consulted only when the
|
||||
* config has no child_order — explicit configuration wins. */
|
||||
private _discoveredChildSlugs(): string[] {
|
||||
const household = this._state(
|
||||
"sensor.nestquest_household_quests_due_today"
|
||||
);
|
||||
const roster = household?.attributes?.child_roster;
|
||||
if (!Array.isArray(roster)) {
|
||||
if (!Array.isArray(order)) {
|
||||
return [];
|
||||
}
|
||||
return roster
|
||||
.map((entry) => asRosterSlug(entry))
|
||||
.filter((slug) => slug.length > 0);
|
||||
return order
|
||||
.filter(
|
||||
(entry): entry is string =>
|
||||
typeof entry === "string" && entry.trim().length > 0
|
||||
)
|
||||
.map((entry) => entry.trim());
|
||||
}
|
||||
|
||||
private _plates(): ChildPlate[] {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import "./cards/party-board";
|
||||
import "./cards/quest-log";
|
||||
import "./cards/admin";
|
||||
import "./strategy";
|
||||
|
||||
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
|
||||
if (!document.querySelector('link[data-nq-fonts=""]')) {
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import type { HassLike } from "./types";
|
||||
|
||||
type StateObject = {
|
||||
state: unknown;
|
||||
attributes?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type RosterEntry = {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type View = {
|
||||
path?: string;
|
||||
title?: string;
|
||||
type: string;
|
||||
cards: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
type LovelaceConfig = {
|
||||
views: View[];
|
||||
};
|
||||
|
||||
type StrategyConfig = Record<string, unknown>;
|
||||
|
||||
type StrategyClass = {
|
||||
generate: (
|
||||
config: StrategyConfig,
|
||||
hass: HassLike | undefined
|
||||
) => Promise<LovelaceConfig>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
customStrategies?: Record<string, StrategyClass>;
|
||||
}
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/** The slug a discovered roster entry carries, or "" when the entry is
|
||||
* not an object with a non-empty slug string — the same shape check
|
||||
* the party board applies to the same attribute. */
|
||||
function asRosterSlug(entry: unknown): string {
|
||||
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return "";
|
||||
}
|
||||
const slug = (entry as Record<string, unknown>).slug;
|
||||
return typeof slug === "string" ? slug.trim() : "";
|
||||
}
|
||||
|
||||
/** The dashboard's URL path segment, used to COMPUTE the card paths the
|
||||
* navigation needs. A dashboard lives at a single top-level segment
|
||||
* (/<url_path>), so the first pathname segment is the dashboard root —
|
||||
* this is where the strategy renders, and navigating back to it shows
|
||||
* the first view (the party board). An explicit ``url_path`` in the
|
||||
* strategy configuration wins for setups that need to pin it. */
|
||||
function dashboardUrlPath(config: StrategyConfig): string {
|
||||
const configured = asString(config.url_path).replace(/^\/+|\/+$/g, "");
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
const segment = window.location.pathname.split("/").filter(Boolean)[0];
|
||||
return segment ?? "";
|
||||
}
|
||||
|
||||
/** The display name a roster entry carries, or "" when it is not a
|
||||
* non-empty string — the roster's ``name`` field (sensor.py
|
||||
* ``_child_roster``), used for the quest-log view titles. */
|
||||
function asRosterName(entry: unknown): string {
|
||||
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return "";
|
||||
}
|
||||
const name = (entry as Record<string, unknown>).name;
|
||||
return typeof name === "string" ? name.trim() : "";
|
||||
}
|
||||
|
||||
/** The ordered child roster the integration publishes on the household
|
||||
* rollup (custom_components/nestquest/sensor.py — ``child_roster``,
|
||||
* the snapshot's sort_order, then id). The same source the party
|
||||
* board's plate discovery reads, consumed here at render time so the
|
||||
* quest-log views exist for exactly the active children. */
|
||||
function rosterEntries(hass: HassLike | undefined): RosterEntry[] {
|
||||
const states = hass?.states as Record<string, StateObject> | undefined;
|
||||
const household = states?.["sensor.nestquest_household_quests_due_today"];
|
||||
const roster = household?.attributes?.child_roster;
|
||||
if (!Array.isArray(roster)) {
|
||||
return [];
|
||||
}
|
||||
return roster
|
||||
.map((entry) => ({ slug: asRosterSlug(entry), name: asRosterName(entry) }))
|
||||
.filter((entry) => entry.slug.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-config NestQuest dashboard strategy (Feature 20).
|
||||
*
|
||||
* A dashboard configured with ``strategy: { type: custom:nestquest-party }``
|
||||
* gets its views GENERATED at render time from the household roster: one
|
||||
* party-board view (the dashboard root) and one quest-log view per child,
|
||||
* ordered by the roster's sort_order. The paths the cards navigate by are
|
||||
* COMPUTED, not configured:
|
||||
*
|
||||
* - the board's ``quest_log_path`` is the dashboard root; tapping a crest
|
||||
* pushes ``<root>/<slug>`` (party-board.ts ``_openQuestLog``);
|
||||
* - each log view's ``path`` IS the child's slug, so the log resolves its
|
||||
* child from the last pathname segment (quest-log.ts
|
||||
* ``childSlugFromPath``) and its ``board_path`` back to the dashboard
|
||||
* root (quest-log.ts ``_returnToBoard``).
|
||||
*
|
||||
* With zero children the board view still renders — the board card shows
|
||||
* its own "not set up yet" notice for an empty roster, so no log views are
|
||||
* needed. ``url_path`` and ``weather_entity`` may still be set explicitly
|
||||
* in the strategy configuration; everything else is derived.
|
||||
*/
|
||||
export class NestQuestPartyStrategy {
|
||||
static async generate(
|
||||
config: StrategyConfig,
|
||||
hass: HassLike | undefined
|
||||
): Promise<LovelaceConfig> {
|
||||
const root = `/${dashboardUrlPath(config)}`;
|
||||
const weatherEntity = asString(config.weather_entity);
|
||||
const boardCard: Record<string, unknown> = {
|
||||
type: "custom:nestquest-party-board-card",
|
||||
quest_log_path: root,
|
||||
};
|
||||
if (weatherEntity) {
|
||||
boardCard.weather_entity = weatherEntity;
|
||||
}
|
||||
const logCard: Record<string, unknown> = {
|
||||
type: "custom:nestquest-quest-log-card",
|
||||
board_path: root,
|
||||
};
|
||||
if (weatherEntity) {
|
||||
logCard.weather_entity = weatherEntity;
|
||||
}
|
||||
return {
|
||||
views: [
|
||||
{
|
||||
path: "party",
|
||||
title: "The Party",
|
||||
type: "panel",
|
||||
cards: [boardCard],
|
||||
},
|
||||
...rosterEntries(hass).map<View>((entry) => ({
|
||||
path: entry.slug,
|
||||
title: entry.name
|
||||
? `${entry.name}'s Quest Log`
|
||||
: `${entry.slug}'s Quest Log`,
|
||||
type: "panel",
|
||||
cards: [{ ...logCard }],
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
window.customStrategies = window.customStrategies ?? {};
|
||||
window.customStrategies["nestquest-party"] = NestQuestPartyStrategy;
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Render the built party-board card under jsdom and print its plates.
|
||||
*
|
||||
* Usage: node render-party-board.mjs < spec.json
|
||||
*
|
||||
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
|
||||
* stdout receives one JSON object {plates, tappable, location, html} where
|
||||
* plates is the ordered plate list the shadow DOM renders (name +
|
||||
* present/away/unknown kind), tappable the plate names rendered as tappable
|
||||
* <button> plates, and location the window pathname after the optional plate
|
||||
* tap. The Python panel tests drive this harness so the assertions run
|
||||
* against the exact bundle HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const spec = JSON.parse(readFileSync(0, "utf8"));
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: spec.url ?? "http://homeassistant.local/nestquest/board",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
|
||||
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
|
||||
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
if (!(key in globalThis)) {
|
||||
try {
|
||||
globalThis[key] = dom.window[key];
|
||||
} catch {
|
||||
// jsdom getters may throw on access; the card does not need them.
|
||||
}
|
||||
}
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
// The card navigates by dispatching new Event("location-changed") on this
|
||||
// window; jsdom's EventTarget rejects Node's own global Event class, so the
|
||||
// jsdom Event must shadow it for the dispatched event to be delivered.
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const element = document.createElement(spec.tag);
|
||||
element.setConfig(spec.config);
|
||||
element.hass = spec.hass;
|
||||
document.body.appendChild(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const plateNodes = [
|
||||
...element.shadowRoot.querySelectorAll(".plates .plate"),
|
||||
];
|
||||
const plates = plateNodes.map((plate) => ({
|
||||
name: plate.querySelector(".name")?.textContent?.trim() ?? "",
|
||||
kind: plate.classList.contains("unknown")
|
||||
? "unknown"
|
||||
: plate.classList.contains("away")
|
||||
? "away"
|
||||
: "present",
|
||||
}));
|
||||
const tappable = plateNodes
|
||||
.filter((plate) => plate.tagName === "BUTTON")
|
||||
.map((plate) => plate.querySelector(".name")?.textContent?.trim() ?? "");
|
||||
|
||||
if (spec.click_plate !== undefined) {
|
||||
const plate = plateNodes.find(
|
||||
(candidate) =>
|
||||
candidate.querySelector(".name")?.textContent?.trim() === spec.click_plate
|
||||
);
|
||||
plate?.click();
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
plates,
|
||||
tappable,
|
||||
location: window.location.pathname,
|
||||
html: element.shadowRoot.innerHTML,
|
||||
});
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Render the built quest-log card under jsdom and report its location.
|
||||
*
|
||||
* Usage: node render-quest-log.mjs < spec.json
|
||||
*
|
||||
* The spec JSON carries {bundle, url, config, hass, idle_ms?, probe_ms?};
|
||||
* the card is mounted at the given URL (it resolves its child from the
|
||||
* pathname's last segment), after the optional probe wait one snapshot of
|
||||
* the render is taken mid-wait, then after the optional idle wait stdout
|
||||
* receives one JSON object {location, headline, countdown, html, probe} —
|
||||
* the final window pathname, the rendered title, the complete-screen
|
||||
* countdown text, the rendered shadow DOM, and the probe snapshot when
|
||||
* probe_ms was given. The Python panel tests drive this harness so the
|
||||
* assertions run against the exact bundle HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const spec = JSON.parse(readFileSync(0, "utf8"));
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: spec.url ?? "http://homeassistant.local/nestquest/ada",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
|
||||
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
|
||||
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
if (!(key in globalThis)) {
|
||||
try {
|
||||
globalThis[key] = dom.window[key];
|
||||
} catch {
|
||||
// jsdom getters may throw on access; the card does not need them.
|
||||
}
|
||||
}
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
// The card navigates by dispatching new Event("location-changed") on this
|
||||
// window; jsdom's EventTarget rejects Node's own global Event class, so the
|
||||
// jsdom Event must shadow it for the return navigation to be delivered.
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const element = document.createElement("nestquest-quest-log-card");
|
||||
element.setConfig(spec.config);
|
||||
element.hass = spec.hass;
|
||||
document.body.appendChild(element);
|
||||
await element.updateComplete;
|
||||
|
||||
const snapshot = () => ({
|
||||
location: window.location.pathname,
|
||||
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
|
||||
countdown:
|
||||
element.shadowRoot.querySelector(".complete-countdown")?.textContent?.trim() ??
|
||||
"",
|
||||
html: element.shadowRoot.innerHTML,
|
||||
});
|
||||
|
||||
let probe = null;
|
||||
if (spec.probe_ms !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, spec.probe_ms));
|
||||
probe = snapshot();
|
||||
}
|
||||
|
||||
if (spec.idle_ms !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ ...snapshot(), probe });
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Import the built bundle into jsdom and generate the dashboard strategy's
|
||||
* views.
|
||||
*
|
||||
* Usage: node render-strategy.mjs < spec.json
|
||||
*
|
||||
* The spec JSON carries {bundle, url, config, hass}; stdout receives one
|
||||
* JSON object {views} — the strategy output's views array with the paths
|
||||
* and card configs the generated dashboard renders. The Python panel
|
||||
* tests drive this harness so the assertions run against the exact bundle
|
||||
* HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const spec = JSON.parse(readFileSync(0, "utf8"));
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: spec.url ?? "http://homeassistant.local/nestquest/party",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
|
||||
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
|
||||
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
if (!(key in globalThis)) {
|
||||
try {
|
||||
globalThis[key] = dom.window[key];
|
||||
} catch {
|
||||
// jsdom getters may throw on access; the strategy does not need them.
|
||||
}
|
||||
}
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const strategy = window.customStrategies?.["nestquest-party"];
|
||||
if (!strategy) {
|
||||
console.error("nestquest-party strategy is not registered on the bundle");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const lovelace = await strategy.generate(spec.config, spec.hass);
|
||||
const payload = JSON.stringify({ views: lovelace.views });
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
@@ -1,86 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RENDER = runpy.run_path(str(ROOT / "scripts/render-cycle-prompt"))["render"]
|
||||
TEMPLATE = (ROOT / "templates/ORCHESTRATOR-PROMPT.template.md").read_text()
|
||||
PROFILE = json.loads((ROOT / "templates/nestquest.profile.json").read_text())
|
||||
|
||||
|
||||
class CyclePromptTests(unittest.TestCase):
|
||||
def test_project_profile_renders_without_specific_source_names(self):
|
||||
other = PROFILE.copy()
|
||||
other.update({
|
||||
"PROJECT_NAME": "ExampleQuest",
|
||||
"PROJECT_CONTEXT_LABEL": "EXAMPLEQUEST COORDINATION",
|
||||
"WORK_TRACKER": "TaskGrid",
|
||||
"CONTEXT_STORE": "ContextHub",
|
||||
"INTEGRATION_BRANCH": "integration",
|
||||
"RELEASE_BRANCH": "production",
|
||||
"CONTROL_HOME": "/opt/examplequest/controller",
|
||||
"SERVICE_NAME": "examplequest-controller.service",
|
||||
"PROJECT_ROOT": "/srv/examplequest",
|
||||
"TERMINAL_LAUNCHER": "eq-orchestrate",
|
||||
"FEATURE_BRANCH_EXAMPLE": "feature/EQ-17",
|
||||
})
|
||||
output = RENDER(TEMPLATE, other)
|
||||
self.assertIn("ExampleQuest Persistent Autonomous", output)
|
||||
self.assertIn("TaskGrid", output)
|
||||
self.assertIn("ContextHub", output)
|
||||
self.assertIn("feature/EQ-17", output)
|
||||
self.assertNotIn("NestQuest", output)
|
||||
self.assertNotIn("nestquest", output)
|
||||
self.assertNotIn("Maestro", output)
|
||||
self.assertNotIn("CTXD", output)
|
||||
self.assertNotIn("{{", output)
|
||||
|
||||
def test_missing_value_fails_before_publication(self):
|
||||
other = PROFILE.copy()
|
||||
del other["PROJECT_NAME"]
|
||||
with self.assertRaisesRegex(ValueError, "PROJECT_NAME"):
|
||||
RENDER(TEMPLATE, other)
|
||||
|
||||
def test_template_contains_only_terminal_runtime(self):
|
||||
output = RENDER(TEMPLATE, PROFILE)
|
||||
self.assertIn("# 27. FOREGROUND TERMINAL RUNTIME", output)
|
||||
self.assertIn("nq-orchestrate", output)
|
||||
self.assertNotIn("# 27. HOST CONTROLLER RUNTIME", output)
|
||||
self.assertNotIn("The persistent user service runs", output)
|
||||
self.assertNotIn("---\n\n---\n\n# 27", output)
|
||||
|
||||
def test_task_complete_feature_triggers_one_guarded_promotion(self):
|
||||
output = RENDER(TEMPLATE, PROFILE)
|
||||
self.assertIn(
|
||||
"feature becomes task-complete and its reviewed merge into `dev` is verified",
|
||||
output,
|
||||
)
|
||||
self.assertIn("Every required feature task is `done`", output)
|
||||
self.assertIn("No task review, remediation, approval, or merge remains pending", output)
|
||||
self.assertIn("feature acceptance criteria and integrated checks pass", output)
|
||||
self.assertIn("Independent approval applies to the exact feature head", output)
|
||||
self.assertIn("approver's merge of that head into `dev` is verified", output)
|
||||
self.assertIn("reflects the completed feature and exact repository references", output)
|
||||
self.assertIn("Task completion inside an unmerged feature branch does not trigger", output)
|
||||
self.assertIn("Do not create a duplicate promotion", output)
|
||||
self.assertIn("Allow only one active promotion pull request to `main`", output)
|
||||
self.assertIn("if it was closed without merge, recalculate", output)
|
||||
self.assertIn("sole active orchestrator for the project", output)
|
||||
self.assertIn("including controller exclusivity, reconciliation", output)
|
||||
self.assertIn("RELEASE_PREPARATION_POLICY = EXCLUDED", output)
|
||||
self.assertIn("Active promotion branch and pull request", output)
|
||||
self.assertIn("An active promotion that has not reached `RELEASE_READY`", output)
|
||||
self.assertIn("RELEASE_PREPARATION_POLICY:\n", output)
|
||||
self.assertIn("A ready promotion is a local checkpoint", output)
|
||||
self.assertIn("continue that work while the ready promotion waits", output)
|
||||
self.assertLess(
|
||||
output.index("Begin an automatically triggered, requested, or scheduled release"),
|
||||
output.index("Advance to the next eligible feature"),
|
||||
)
|
||||
self.assertIn("Only Joshua may merge into `main`", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,88 +0,0 @@
|
||||
from pathlib import Path
|
||||
import contextlib
|
||||
import io
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
APPROVE = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-approve-merge"))
|
||||
HEAD = "a" * 40
|
||||
|
||||
|
||||
class MergeGateTests(unittest.TestCase):
|
||||
def test_main_rejects_moved_head_unmergeable_and_pending_checks(self):
|
||||
root = Path("/tmp/opencode")
|
||||
root.mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=root) as directory:
|
||||
review_file = Path(directory) / "review.txt"
|
||||
review_file.write_text(f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n")
|
||||
for problem in ("moved", "unmergeable", "pending", "none"):
|
||||
with self.subTest(problem=problem):
|
||||
merged = False
|
||||
calls = []
|
||||
def fake_request(path, token, payload=None):
|
||||
nonlocal merged
|
||||
calls.append((path, payload))
|
||||
if path.endswith("/reviews"):
|
||||
return [{"id": 1, "state": "APPROVED", "commit_id": HEAD,
|
||||
"user": {"login": "review_agent"}}]
|
||||
if path.endswith("/status"):
|
||||
return {"total_count": 1, "state": "pending" if problem == "pending" else "success"}
|
||||
if path.endswith("/merge"):
|
||||
merged = True
|
||||
return {}
|
||||
if path.endswith("/pulls/149"):
|
||||
return {"number": 149, "base": {"ref": "dev"},
|
||||
"head": {"sha": "b" * 40 if problem == "moved" else HEAD},
|
||||
"state": "open", "merged": merged,
|
||||
"mergeable": problem != "unmergeable"}
|
||||
raise AssertionError(path)
|
||||
globals_ = APPROVE["main"].__globals__
|
||||
with patch.dict(globals_, {"request": fake_request,
|
||||
"token_from_file": lambda: "test"}), \
|
||||
patch.object(sys, "argv", ["nq-approve-merge", "merge", "149", HEAD,
|
||||
str(review_file)]), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
if problem == "none":
|
||||
self.assertEqual(APPROVE["main"](), 0)
|
||||
else:
|
||||
with self.assertRaises(RuntimeError):
|
||||
APPROVE["main"]()
|
||||
self.assertEqual(merged, problem == "none")
|
||||
if problem == "none":
|
||||
merge_calls = [payload for path, payload in calls if path.endswith("/merge")]
|
||||
self.assertEqual(merge_calls,
|
||||
[{"do": "squash", "head_commit_id": HEAD}])
|
||||
|
||||
def test_review_evidence_must_match_exact_head_and_approval(self):
|
||||
root = Path("/tmp/opencode")
|
||||
root.mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=root) as directory:
|
||||
review = Path(directory) / "review.txt"
|
||||
review.write_text(f"Findings: none\nREVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n")
|
||||
APPROVE["validate_review"](review, HEAD)
|
||||
with self.assertRaisesRegex(RuntimeError, "exact head"):
|
||||
APPROVE["validate_review"](review, "b" * 40)
|
||||
review.write_text(f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: CHANGES_REQUIRED\n")
|
||||
with self.assertRaisesRegex(RuntimeError, "exact head"):
|
||||
APPROVE["validate_review"](review, HEAD)
|
||||
|
||||
def test_requires_distinct_gitea_reviewer_on_exact_commit(self):
|
||||
records = [{"state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}}]
|
||||
with patch.dict(APPROVE["has_platform_review"].__globals__, {"request": lambda *_: records}):
|
||||
self.assertTrue(APPROVE["has_platform_review"](149, HEAD, "unused"))
|
||||
self.assertFalse(APPROVE["has_platform_review"](149, "b" * 40, "unused"))
|
||||
records[0]["user"]["login"] = "orchestrator-agent"
|
||||
self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused"))
|
||||
records[:] = [
|
||||
{"id": 1, "state": "APPROVED", "commit_id": HEAD, "user": {"login": "review_agent"}},
|
||||
{"id": 2, "state": "REQUEST_CHANGES", "commit_id": HEAD, "user": {"login": "review_agent"}},
|
||||
]
|
||||
self.assertFalse(APPROVE["has_platform_review"](149, HEAD, "unused"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,108 +0,0 @@
|
||||
import json
|
||||
import io
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
CONTROL = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-controller"))
|
||||
|
||||
|
||||
class ControllerProtocolTests(unittest.TestCase):
|
||||
def test_ctxd_preflight_reads_configured_endpoint_and_checks_identity(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
config = root / ".config/opencode/opencode.json"
|
||||
config.parent.mkdir(parents=True)
|
||||
config.write_text(json.dumps({"mcp": {"ctxd": {
|
||||
"url": "http://localhost:9091/mcp",
|
||||
"headers": {"Authorization": "Bearer test"},
|
||||
}}}))
|
||||
record = {"project_id": CONTROL["CTXD_PROJECT"],
|
||||
"file_path": CONTROL["CTXD_FILE"],
|
||||
"content": "# CONTROLLER_TICK", "version": 6}
|
||||
with patch.object(Path, "home", return_value=root), \
|
||||
patch.dict(CONTROL["check_ctxd"].__globals__["urllib"].request.__dict__,
|
||||
{"urlopen": lambda request, timeout: io.BytesIO(json.dumps(record).encode())}):
|
||||
self.assertEqual(CONTROL["check_ctxd"](), 6)
|
||||
record["project_id"] = "wrong"
|
||||
with self.assertRaisesRegex(RuntimeError, "wrong NestQuest prompt"):
|
||||
CONTROL["check_ctxd"]()
|
||||
record["project_id"] = CONTROL["CTXD_PROJECT"]
|
||||
config.write_text(json.dumps({"mcp": {"ctxd": {
|
||||
"url": "http://localhost:9091/mcp/",
|
||||
"headers": {"Authorization": "Bearer test"},
|
||||
}}}))
|
||||
self.assertEqual(CONTROL["check_ctxd"](), 6)
|
||||
config.write_text(json.dumps({"mcp": {"ctxd": {
|
||||
"url": "http://localhost:9091/invalid",
|
||||
"headers": {"Authorization": "Bearer test"},
|
||||
}}}))
|
||||
with self.assertRaisesRegex(RuntimeError, "must end in /mcp"):
|
||||
CONTROL["check_ctxd"]()
|
||||
|
||||
def test_installed_opencode_text_event(self):
|
||||
event = {"type": "text", "sessionID": "ses_test", "part": {"type": "text", "text": "Next task queued.\nORCHESTRATOR_STATE: CONTINUE\n"}}
|
||||
self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (event["part"]["text"], None))
|
||||
self.assertEqual(CONTROL["STATE_RE"].findall(event["part"]["text"]), ["CONTINUE"])
|
||||
|
||||
def test_error_event_does_not_publish_provider_message(self):
|
||||
event = {"type": "error", "error": {"name": "APIError", "data": {"message": "secret provider response"}}}
|
||||
self.assertEqual(CONTROL["parse_event"](json.dumps(event)), (None, "APIError"))
|
||||
self.assertEqual(CONTROL["parse_event"]("null"), (None, None))
|
||||
self.assertEqual(CONTROL["parse_event"]("[]"), (None, None))
|
||||
|
||||
def test_tick_uses_final_text(self):
|
||||
event = json.dumps({"type": "text", "part": {"type": "text", "text": "ORCHESTRATOR_STATE: QUIESCENT\n"}})
|
||||
command = [sys.executable, "-c", f"import json,sys; sys.stdin.read(); print({event!r})"]
|
||||
status, answer, error = CONTROL["run_tick"](command, {}, "tick", 10)
|
||||
self.assertEqual((status, error), (0, None))
|
||||
self.assertEqual(CONTROL["STATE_RE"].findall(answer), ["QUIESCENT"])
|
||||
|
||||
def test_timeout_kills_child_group_and_marks_tick_failed(self):
|
||||
command = [sys.executable, "-c", "import sys,time; sys.stdin.read(); time.sleep(10)"]
|
||||
status, answer, error = CONTROL["run_tick"](command, {}, "tick", 1)
|
||||
self.assertEqual(status, 124)
|
||||
self.assertEqual(answer, "")
|
||||
self.assertIn("timeout", error)
|
||||
|
||||
def test_interactive_session_detects_dir_and_resumed_session(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
project = root / "project"
|
||||
project.mkdir()
|
||||
proc = root / "proc"
|
||||
entry = proc / "123"
|
||||
entry.mkdir(parents=True)
|
||||
(entry / "cwd").symlink_to(root)
|
||||
database = root / "opencode.db"
|
||||
with sqlite3.connect(database) as db:
|
||||
db.execute("CREATE TABLE session (id TEXT, directory TEXT)")
|
||||
db.execute("INSERT INTO session VALUES (?, ?)", ("ses_test", str(project)))
|
||||
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(project).encode() + b"\0")
|
||||
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||
(entry / "cmdline").write_bytes(b"opencode\0web\0--dir\0" + str(project).encode() + b"\0")
|
||||
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||
(entry / "cmdline").write_bytes(b"opencode\0serve\0")
|
||||
(entry / "cwd").unlink()
|
||||
(entry / "cwd").symlink_to(project)
|
||||
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||
(entry / "cwd").unlink()
|
||||
(entry / "cwd").symlink_to(root)
|
||||
(entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0")
|
||||
self.assertTrue(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||
(entry / "cmdline").write_bytes(b"opencode\0--dir\0" + str(root).encode() + b"\0")
|
||||
self.assertFalse(CONTROL["interactive_nestquest_session"](project, proc, database))
|
||||
with sqlite3.connect(database) as db:
|
||||
db.execute("DROP TABLE session")
|
||||
(entry / "cmdline").write_bytes(b"opencode\0-s\0ses_test\0")
|
||||
with self.assertRaisesRegex(RuntimeError, "lookup failed"):
|
||||
CONTROL["interactive_nestquest_session"](project, proc, database)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,91 +0,0 @@
|
||||
from pathlib import Path
|
||||
import fcntl
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts/nq-orchestrate"
|
||||
|
||||
|
||||
class ForegroundLauncherTests(unittest.TestCase):
|
||||
def test_launches_terminal_agent_with_rendered_and_user_prompt(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
control = root / "control"
|
||||
scripts = control / "scripts"
|
||||
agents = control / ".opencode/agents"
|
||||
templates = control / "templates"
|
||||
project = root / "project"
|
||||
for path in (scripts, agents, templates, project):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(templates / "nestquest.profile.json").write_text("{}")
|
||||
(agents / "terminal-orchestrator.md").write_text("agent")
|
||||
renderer = scripts / "render-cycle-prompt"
|
||||
renderer.write_text("#!/bin/sh\nprintf 'BASE TERMINAL PROMPT'")
|
||||
controller = scripts / "nq-controller"
|
||||
controller_args = root / "controller-args"
|
||||
open_marker = root / "open-session"
|
||||
controller.write_text(
|
||||
f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {controller_args}\n"
|
||||
f"if test -f {open_marker}; then printf 'interactive NestQuest session open\\n'; "
|
||||
"else printf 'no interactive NestQuest session\\n'; fi\n"
|
||||
)
|
||||
opencode = root / "opencode"
|
||||
output = root / "args"
|
||||
opencode.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {output}\n")
|
||||
systemctl = root / "systemctl"
|
||||
stopped = root / "stopped"
|
||||
systemctl.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {stopped}\n")
|
||||
for executable in (renderer, controller, opencode, systemctl):
|
||||
executable.chmod(0o700)
|
||||
mission = root / "mission.md"
|
||||
mission.write_text("Complete feature EQ-17.")
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"NQ_CONTROL_HOME": str(control),
|
||||
"NQ_PROJECT_ROOT": str(project),
|
||||
"OPENCODE_BIN": str(opencode),
|
||||
"SYSTEMCTL_BIN": str(systemctl),
|
||||
"NQ_STATE_DIR": str(root / "state"),
|
||||
})
|
||||
printed = subprocess.run([str(SCRIPT), "--print-prompt"], env=env,
|
||||
text=True, capture_output=True)
|
||||
self.assertEqual(printed.returncode, 0, printed.stderr)
|
||||
self.assertEqual(printed.stdout, "BASE TERMINAL PROMPT\n")
|
||||
self.assertFalse(stopped.exists())
|
||||
result = subprocess.run([str(SCRIPT), str(mission)], env=env, text=True,
|
||||
capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
args = output.read_text()
|
||||
self.assertIn("--agent\nterminal-orchestrator", args)
|
||||
self.assertIn("BASE TERMINAL PROMPT", args)
|
||||
self.assertIn("# USER MISSION", args)
|
||||
self.assertIn("Complete feature EQ-17.", args)
|
||||
self.assertEqual(stopped.read_text().splitlines(),
|
||||
["--user", "stop", "nestquest-controller.service"])
|
||||
self.assertEqual(controller_args.read_text().splitlines(),
|
||||
["--check-interactive", "--project", str(project)])
|
||||
|
||||
output.unlink()
|
||||
lock_path = root / "state/controller.lock"
|
||||
with lock_path.open("w") as held_lock:
|
||||
fcntl.flock(held_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
locked = subprocess.run([str(SCRIPT)], env=env, text=True,
|
||||
capture_output=True)
|
||||
self.assertEqual(locked.returncode, 75)
|
||||
self.assertIn("Another NestQuest orchestrator or controller",
|
||||
locked.stderr)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
open_marker.touch()
|
||||
refused = subprocess.run([str(SCRIPT)], env=env, text=True,
|
||||
capture_output=True)
|
||||
self.assertEqual(refused.returncode, 75)
|
||||
self.assertIn("Another interactive NestQuest", refused.stderr)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,17 +0,0 @@
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import unittest
|
||||
|
||||
REVIEW = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/nq-review"))
|
||||
HEAD = "a" * 40
|
||||
|
||||
class ReviewLauncherTests(unittest.TestCase):
|
||||
def test_exact_head_verdict(self):
|
||||
self.assertEqual(REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\n", HEAD), "APPROVED")
|
||||
with self.assertRaisesRegex(RuntimeError, "one verdict"):
|
||||
REVIEW["verdict"](f"REVIEWED_HEAD: {'b' * 40}\nREVIEW_VERDICT: APPROVED\n", HEAD)
|
||||
with self.assertRaisesRegex(RuntimeError, "one verdict"):
|
||||
REVIEW["verdict"](f"REVIEWED_HEAD: {HEAD}\nREVIEW_VERDICT: APPROVED\nREVIEW_VERDICT: CHANGES_REQUIRED\n", HEAD)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.6.0"
|
||||
version = "0.5.0"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -21,7 +21,6 @@ dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pyyaml>=6",
|
||||
"unidecode>=1.4.0",
|
||||
"uvicorn>=0.30",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 4 ]]; then
|
||||
echo "usage: nq-agent developer|approver WORKTREE TITLE PROMPT_FILE" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
role=$1
|
||||
worktree=$2
|
||||
title=$3
|
||||
prompt_file=$4
|
||||
case "$role" in developer|approver) ;; *) echo "invalid role: $role" >&2; exit 64 ;; esac
|
||||
[[ -d "$worktree" && -f "$prompt_file" ]] || { echo "missing worktree or prompt" >&2; exit 66; }
|
||||
|
||||
repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
export OPENCODE_CONFIG_DIR="$repo_dir/.opencode"
|
||||
opencode_bin=${OPENCODE_BIN:-/home/overseer/.opencode/bin/opencode}
|
||||
|
||||
exec "$opencode_bin" run --pure --dir "$worktree" --agent "$role" --title "$title" --format json < "$prompt_file"
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspect or merge a reviewed NestQuest feature PR into dev only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
API = "https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/NestQuest"
|
||||
CREDENTIALS = Path("/home/overseer/.llms/credentials.env")
|
||||
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
def token_from_file() -> str:
|
||||
for line in CREDENTIALS.read_text().splitlines():
|
||||
if line.startswith("GITEA_APPROVER_AGENT_TOKEN="):
|
||||
return line.split("=", 1)[1].strip().strip("\"'")
|
||||
raise RuntimeError("GITEA_APPROVER_AGENT_TOKEN is unavailable")
|
||||
|
||||
|
||||
def request(path: str, token: str, payload: dict | None = None) -> dict | list:
|
||||
body = None if payload is None else json.dumps(payload).encode()
|
||||
headers = {"Authorization": "token " + token, "Accept": "application/json"}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
call = urllib.request.Request(API + path, data=body, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(call, timeout=20) as response:
|
||||
raw = response.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as error:
|
||||
raise RuntimeError(f"Gitea returned HTTP {error.code}") from None
|
||||
|
||||
|
||||
def inspect(pr_number: int, token: str) -> dict:
|
||||
pr = request(f"/pulls/{pr_number}", token)
|
||||
head = (pr.get("head") or {}).get("sha")
|
||||
base = (pr.get("base") or {}).get("ref")
|
||||
if not isinstance(head, str) or not FULL_SHA.fullmatch(head):
|
||||
raise RuntimeError("Gitea returned an invalid PR head")
|
||||
status = request(f"/commits/{head}/status", token)
|
||||
return {
|
||||
"number": pr.get("number"),
|
||||
"base": base,
|
||||
"head": head,
|
||||
"state": pr.get("state"),
|
||||
"merged": pr.get("merged"),
|
||||
"mergeable": pr.get("mergeable"),
|
||||
"check_count": status.get("total_count") or 0,
|
||||
"check_state": status.get("state"),
|
||||
}
|
||||
|
||||
|
||||
def validate_review(path: Path, head: str) -> None:
|
||||
if not path.is_file() or not path.resolve().is_relative_to(Path("/tmp/opencode").resolve()):
|
||||
raise RuntimeError("review evidence must be a file under /tmp/opencode")
|
||||
text = path.read_text(errors="replace")
|
||||
reviewed = re.findall(r"(?m)^REVIEWED_HEAD:\s*([0-9a-f]{40})\s*$", text)
|
||||
verdicts = re.findall(r"(?m)^REVIEW_VERDICT:\s*(APPROVED|CHANGES_REQUIRED)\s*$", text)
|
||||
if len(reviewed) != 1 or len(verdicts) != 1 or reviewed[0] != head or verdicts[0] != "APPROVED":
|
||||
raise RuntimeError("independent review does not approve this exact head")
|
||||
|
||||
|
||||
def has_platform_review(pr_number: int, head: str, token: str) -> bool:
|
||||
reviews = request(f"/pulls/{pr_number}/reviews", token)
|
||||
if not isinstance(reviews, list):
|
||||
raise RuntimeError("Gitea returned invalid review records")
|
||||
by_reviewer = [
|
||||
review for review in reviews
|
||||
if isinstance(review, dict)
|
||||
and (review.get("user") or {}).get("login") == "review_agent"
|
||||
and review.get("commit_id") == head
|
||||
]
|
||||
if not by_reviewer:
|
||||
return False
|
||||
latest = max(by_reviewer, key=lambda review: int(review.get("id") or 0))
|
||||
return latest.get("state") == "APPROVED"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("operation", choices=("inspect", "merge"))
|
||||
parser.add_argument("pr", type=int)
|
||||
parser.add_argument("reviewed_head", nargs="?")
|
||||
parser.add_argument("review_file", nargs="?", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.pr < 1:
|
||||
parser.error("PR number must be positive")
|
||||
if args.operation == "merge" and (not args.reviewed_head or not args.review_file):
|
||||
parser.error("merge requires reviewed_head and review_file")
|
||||
token = token_from_file()
|
||||
before = inspect(args.pr, token)
|
||||
if args.operation == "inspect":
|
||||
print(json.dumps(before, sort_keys=True))
|
||||
return 0
|
||||
if not FULL_SHA.fullmatch(args.reviewed_head):
|
||||
parser.error("reviewed head must be a full SHA")
|
||||
validate_review(args.review_file, args.reviewed_head)
|
||||
if not has_platform_review(args.pr, args.reviewed_head, token):
|
||||
raise RuntimeError("Gitea lacks an exact-head approval by review_agent")
|
||||
if before["base"] != "dev" or before["head"] != args.reviewed_head:
|
||||
raise RuntimeError("PR target or current head differs from the approved review")
|
||||
if before["state"] != "open" or before["merged"] or before["mergeable"] is not True:
|
||||
raise RuntimeError("PR is closed, merged, or not mergeable")
|
||||
if before["check_count"] and before["check_state"] != "success":
|
||||
raise RuntimeError("a configured commit check has not passed")
|
||||
# Gitea rechecks branch protection at the merge endpoint. The caller must
|
||||
# independently verify Maestro and task-level evidence before invoking us.
|
||||
# Gitea checks head_commit_id atomically while processing the merge.
|
||||
request(f"/pulls/{args.pr}/merge", token,
|
||||
{"do": "squash", "head_commit_id": args.reviewed_head})
|
||||
after = inspect(args.pr, token)
|
||||
if after["merged"] is not True or after["head"] != args.reviewed_head:
|
||||
raise RuntimeError("merge result is missing or differs from the reviewed head")
|
||||
print(json.dumps(after, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, ValueError, RuntimeError) as error:
|
||||
print(f"approval gate failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -1,277 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable, single-owner controller for one NestQuest OpenCode tick at a time."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import selectors
|
||||
import signal
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
DEFAULT_PROJECT = Path("/mnt/ai-storage/projects/NestQuest")
|
||||
DEFAULT_STATE = Path.home() / ".local/state/nestquest-controller"
|
||||
STATE_RE = re.compile(r"(?m)^ORCHESTRATOR_STATE: (CONTINUE|QUIESCENT|BLOCKED)\s*$")
|
||||
MAX_TEXT = 64_000
|
||||
MAX_LINE = 4_000_000
|
||||
CTXD_PROJECT = "nestquest-house-chaos-coordination"
|
||||
CTXD_FILE = "OPENCODE-ORCHESTRATOR-PROMPT.MD"
|
||||
|
||||
|
||||
def check_ctxd() -> int:
|
||||
"""Use OpenCode's configured endpoint, never an unrelated host DB."""
|
||||
config = json.loads((Path.home() / ".config/opencode/opencode.json").read_text())
|
||||
mcp = config["mcp"]["ctxd"]
|
||||
configured_url = mcp["url"].rstrip("/")
|
||||
if not configured_url.endswith("/mcp"):
|
||||
raise RuntimeError("CTXD MCP URL must end in /mcp")
|
||||
endpoint = configured_url.removesuffix("/mcp")
|
||||
url = f"{endpoint}/projects/{CTXD_PROJECT}/files/{CTXD_FILE}"
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(url, headers=mcp["headers"]), timeout=8
|
||||
) as response:
|
||||
record = json.load(response)
|
||||
if (
|
||||
record.get("project_id") != CTXD_PROJECT
|
||||
or record.get("file_path") != CTXD_FILE
|
||||
or "CONTROLLER_TICK" not in record.get("content", "")
|
||||
):
|
||||
raise RuntimeError("CTXD returned the wrong NestQuest prompt")
|
||||
return int(record["version"])
|
||||
|
||||
|
||||
def interactive_nestquest_session(
|
||||
project: Path,
|
||||
proc_root: Path = Path("/proc"),
|
||||
database: Path = Path.home() / ".local/share/opencode/opencode.db",
|
||||
) -> bool:
|
||||
"""Avoid racing a user-owned OpenCode TUI for the same repository."""
|
||||
try:
|
||||
db = sqlite3.connect(f"file:{database}?mode=ro", uri=True)
|
||||
except sqlite3.Error as error:
|
||||
raise RuntimeError("OpenCode session database is unavailable") from error
|
||||
try:
|
||||
for entry in proc_root.iterdir():
|
||||
if not entry.name.isdecimal() or int(entry.name) == os.getpid():
|
||||
continue
|
||||
try:
|
||||
args = (entry / "cmdline").read_bytes().split(b"\0")
|
||||
args = [a.decode(errors="replace") for a in args if a]
|
||||
if not args or Path(args[0]).name != "opencode":
|
||||
continue
|
||||
if len(args) > 1 and args[1] in ("run", "debug"):
|
||||
continue
|
||||
session = None
|
||||
for flag in ("-s", "--session"):
|
||||
if flag in args and args.index(flag) + 1 < len(args):
|
||||
session = args[args.index(flag) + 1]
|
||||
break
|
||||
if session:
|
||||
row = db.execute("SELECT directory FROM session WHERE id=?", (session,)).fetchone()
|
||||
if row and Path(row[0]).resolve() == project.resolve():
|
||||
return True
|
||||
elif "--dir" in args and args.index("--dir") + 1 < len(args):
|
||||
if Path(args[args.index("--dir") + 1]).resolve() == project.resolve():
|
||||
return True
|
||||
elif (entry / "cwd").resolve() == project.resolve():
|
||||
return True
|
||||
except sqlite3.Error as error:
|
||||
raise RuntimeError("OpenCode session lookup failed") from error
|
||||
except TypeError as error:
|
||||
raise RuntimeError("OpenCode session directory is invalid") from error
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
finally:
|
||||
db.close()
|
||||
return False
|
||||
|
||||
|
||||
def parse_event(raw: str) -> tuple[str | None, str | None]:
|
||||
try:
|
||||
event = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None, None
|
||||
if not isinstance(event, dict):
|
||||
return None, None
|
||||
kind = event.get("type")
|
||||
part = event.get("part") or {}
|
||||
if kind == "text" and isinstance(part, dict):
|
||||
return part.get("text"), None
|
||||
if kind == "error":
|
||||
error = event.get("error") or {}
|
||||
known = {"ProviderAuthError", "UnknownError", "MessageOutputLengthError", "MessageAbortedError", "StructuredOutputError", "ContextOverflowError", "APIError"}
|
||||
name = error.get("name") if isinstance(error, dict) else None
|
||||
return None, name if name in known else "OpenCode error"
|
||||
return None, None
|
||||
|
||||
|
||||
def run_tick(command: list[str], env: dict[str, str], prompt: str, timeout: int) -> tuple[int, str, str | None]:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
assert process.stdin is not None and process.stdout is not None
|
||||
try:
|
||||
process.stdin.write(prompt.encode())
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
finally:
|
||||
process.stdin.close()
|
||||
selector = selectors.DefaultSelector()
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
deadline = time.monotonic() + timeout
|
||||
buffer = b""
|
||||
texts: list[str] = []
|
||||
last_error: str | None = None
|
||||
diagnostic_lines = 0
|
||||
try:
|
||||
while selector.get_map():
|
||||
if time.monotonic() >= deadline:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait()
|
||||
return 124, "".join(texts)[-MAX_TEXT:], "controller tick exceeded documented timeout"
|
||||
for key, _ in selector.select(timeout=1):
|
||||
chunk = os.read(key.fd, 65536)
|
||||
if not chunk:
|
||||
selector.unregister(key.fileobj)
|
||||
continue
|
||||
buffer += chunk
|
||||
while b"\n" in buffer:
|
||||
line, buffer = buffer.split(b"\n", 1)
|
||||
text, error = parse_event(line.decode(errors="replace"))
|
||||
if not line.lstrip().startswith(b"{"):
|
||||
diagnostic_lines += 1
|
||||
if text:
|
||||
texts.append(text)
|
||||
if sum(map(len, texts)) > MAX_TEXT:
|
||||
texts = ["".join(texts)[-MAX_TEXT:]]
|
||||
if error:
|
||||
last_error = error
|
||||
if len(buffer) > MAX_LINE:
|
||||
buffer = b"" # A tool-output event is not a controller verdict.
|
||||
if buffer:
|
||||
text, error = parse_event(buffer.decode(errors="replace"))
|
||||
if text:
|
||||
texts.append(text)
|
||||
if error:
|
||||
last_error = error
|
||||
code = process.wait()
|
||||
if code and last_error is None and diagnostic_lines:
|
||||
last_error = f"OpenCode emitted {diagnostic_lines} non-JSON diagnostic lines"
|
||||
return code, "".join(texts)[-MAX_TEXT:], last_error
|
||||
finally:
|
||||
selector.close()
|
||||
process.stdout.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--project", type=Path, default=DEFAULT_PROJECT)
|
||||
parser.add_argument("--state-dir", type=Path, default=DEFAULT_STATE)
|
||||
parser.add_argument("--once", action="store_true", help="run only one model turn")
|
||||
parser.add_argument("--check", action="store_true", help="validate paths without calling a model")
|
||||
parser.add_argument("--check-interactive", action="store_true", help="report an open NestQuest TUI without calling a model")
|
||||
parser.add_argument("--tick-timeout", type=int, default=6 * 3600)
|
||||
args = parser.parse_args()
|
||||
|
||||
project = args.project.resolve()
|
||||
control_home = Path(__file__).resolve().parent.parent
|
||||
config_dir = control_home / ".opencode"
|
||||
binary = Path(os.environ.get("OPENCODE_BIN", "/home/overseer/.opencode/bin/opencode"))
|
||||
if not (project.is_dir() and config_dir.is_dir() and binary.is_file()):
|
||||
parser.error("project, agent configuration, or OpenCode binary is missing")
|
||||
if args.check:
|
||||
try:
|
||||
ctxd_version = check_ctxd()
|
||||
except (OSError, KeyError, ValueError, RuntimeError) as error:
|
||||
print(f"CTXD preflight failed: {type(error).__name__}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"ready: project={project} configuration={config_dir} binary={binary} ctxd_prompt_version={ctxd_version}")
|
||||
return 0
|
||||
if args.check_interactive:
|
||||
try:
|
||||
active = interactive_nestquest_session(project)
|
||||
except RuntimeError:
|
||||
print("OpenCode session state unavailable", file=sys.stderr)
|
||||
return 77
|
||||
print("interactive NestQuest session open" if active else "no interactive NestQuest session")
|
||||
return 0
|
||||
|
||||
args.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(args.state_dir, 0o700)
|
||||
with (args.state_dir / "controller.lock").open("w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
print("another NestQuest controller is running", file=sys.stderr)
|
||||
return 75
|
||||
command = [str(binary), "run", "--pure", "--dir", str(project), "--agent", "orchestrator", "--format", "json", "--title", "NestQuest controller tick"]
|
||||
env = os.environ.copy()
|
||||
env["OPENCODE_CONFIG_DIR"] = str(config_dir)
|
||||
env["NESTQUEST_CONTROL_HOME"] = str(control_home)
|
||||
failures = 0
|
||||
while True:
|
||||
try:
|
||||
interactive = interactive_nestquest_session(project)
|
||||
except RuntimeError:
|
||||
print("OpenCode session state unavailable; waiting", file=sys.stderr, flush=True)
|
||||
if args.once:
|
||||
return 77
|
||||
time.sleep(60)
|
||||
continue
|
||||
if interactive:
|
||||
print("interactive NestQuest session still open; waiting", flush=True)
|
||||
if args.once:
|
||||
return 75
|
||||
time.sleep(60)
|
||||
continue
|
||||
prompt = (
|
||||
"Run one NestQuest controller tick. Read CTXD and Maestro afresh, "
|
||||
"reconcile Git and PR state, then execute the next eligible action. "
|
||||
"Use NESTQUEST_CONTROL_HOME for role launch scripts. "
|
||||
"Return a final ORCHESTRATOR_STATE line as your agent instructions require."
|
||||
)
|
||||
status, answer, error = run_tick(command, env, prompt, args.tick_timeout)
|
||||
states = STATE_RE.findall(answer)
|
||||
state = states[-1] if status == 0 and error is None and states else "ERROR"
|
||||
if state == "CONTINUE":
|
||||
failures = 0
|
||||
elif state == "QUIESCENT":
|
||||
failures = 0
|
||||
elif state == "BLOCKED":
|
||||
failures = 0
|
||||
else:
|
||||
failures += 1
|
||||
summary = {"time": int(time.time()), "state": state, "exit_code": status, "error": error, "failure_count": failures, "answer_tail": answer[-4000:]}
|
||||
target = args.state_dir / "last-run.json"
|
||||
temporary = args.state_dir / "last-run.json.tmp"
|
||||
temporary.write_text(json.dumps(summary, indent=2) + "\n")
|
||||
os.chmod(temporary, 0o600)
|
||||
temporary.replace(target)
|
||||
print(f"tick state={state} exit={status} failures={failures}", flush=True)
|
||||
if args.once:
|
||||
return 0 if state in ("CONTINUE", "QUIESCENT") else 77
|
||||
if state == "BLOCKED" or failures >= 3:
|
||||
print("controller blocked; inspect last-run.json", file=sys.stderr)
|
||||
return 77
|
||||
time.sleep(300 if state == "QUIESCENT" else min(120, 10 * 2**failures))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch the NestQuest orchestrator immediately in the foreground terminal.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -gt 1 ]]; then
|
||||
echo "usage: nq-orchestrate [MISSION_PROMPT_FILE|-|--print-prompt]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
control_home=${NQ_CONTROL_HOME:-/home/overseer/.local/share/nestquest-controller}
|
||||
project=${NQ_PROJECT_ROOT:-/mnt/ai-storage/projects/NestQuest}
|
||||
opencode_bin=${OPENCODE_BIN:-/home/overseer/.opencode/bin/opencode}
|
||||
systemctl_bin=${SYSTEMCTL_BIN:-systemctl}
|
||||
state_dir=${NQ_STATE_DIR:-/home/overseer/.local/state/nestquest-controller}
|
||||
renderer="$control_home/scripts/render-cycle-prompt"
|
||||
profile="$control_home/templates/nestquest.profile.json"
|
||||
|
||||
for required in "$renderer" "$profile"; do
|
||||
[[ -e "$required" ]] || { echo "missing required path: $required" >&2; exit 66; }
|
||||
done
|
||||
|
||||
prompt=$("$renderer" "$profile")
|
||||
if [[ ${1:-} == "--print-prompt" ]]; then
|
||||
printf '%s\n' "$prompt"
|
||||
exit 0
|
||||
fi
|
||||
for required in "$project" "$opencode_bin" "$control_home/.opencode/agents/terminal-orchestrator.md"; do
|
||||
[[ -e "$required" ]] || { echo "missing required path: $required" >&2; exit 66; }
|
||||
done
|
||||
if [[ $# -eq 1 ]]; then
|
||||
if [[ "$1" == "-" ]]; then
|
||||
mission=$(cat)
|
||||
else
|
||||
[[ -f "$1" ]] || { echo "mission prompt not found: $1" >&2; exit 66; }
|
||||
mission=$(cat "$1")
|
||||
fi
|
||||
[[ -n "${mission//[[:space:]]/}" ]] || { echo "mission prompt is empty" >&2; exit 65; }
|
||||
prompt+=$'\n\n---\n\n# USER MISSION\n\n'
|
||||
prompt+="$mission"
|
||||
fi
|
||||
|
||||
# Foreground mode owns orchestration. A dormant service must never race it.
|
||||
"$systemctl_bin" --user stop nestquest-controller.service
|
||||
|
||||
# Share the controller's lock and hold it across the final exec. This closes
|
||||
# the launch-time race between two foreground sessions as well as the race with
|
||||
# the background controller.
|
||||
mkdir -p "$state_dir"
|
||||
chmod 700 "$state_dir"
|
||||
exec 9>"$state_dir/controller.lock"
|
||||
if ! flock -n 9; then
|
||||
echo "Another NestQuest orchestrator or controller is running." >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
session_status=$("$control_home/scripts/nq-controller" --check-interactive --project "$project")
|
||||
if [[ "$session_status" == "interactive NestQuest session open" ]]; then
|
||||
echo "Another interactive NestQuest OpenCode session is open." >&2
|
||||
echo "Close it, then run nq-orchestrate again." >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
export OPENCODE_CONFIG_DIR="$control_home/.opencode"
|
||||
exec "$opencode_bin" "$project" --pure --agent terminal-orchestrator --prompt "$prompt"
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Claude on an exact PR head and publish an independent Gitea approval."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
API = "https://code.cubecraftlabs.com/api/v1"
|
||||
REPO = "/repos/Maniacs_Mansion/NestQuest"
|
||||
CREDENTIALS = Path("/home/overseer/.llms/credentials.env")
|
||||
CLAUDE = "/home/overseer/.local/bin/claude"
|
||||
SHA = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
def git(worktree: Path, *args: str) -> str:
|
||||
return subprocess.check_output(["git", "-C", str(worktree), *args], text=True).strip()
|
||||
|
||||
def token() -> str:
|
||||
for line in CREDENTIALS.read_text().splitlines():
|
||||
if line.startswith("GITEA_REVIEW_AGENT_TOKEN="):
|
||||
return line.split("=", 1)[1].strip().strip("\"'")
|
||||
raise RuntimeError("review_agent credential is unavailable")
|
||||
|
||||
def api(path: str, credential: str, payload: dict | None = None) -> dict:
|
||||
body = None if payload is None else json.dumps(payload).encode()
|
||||
headers = {"Authorization": "token " + credential, "Accept": "application/json"}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
call = urllib.request.Request(API + path, data=body, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(call, timeout=20) as response:
|
||||
return json.loads(response.read())
|
||||
except urllib.error.HTTPError as error:
|
||||
raise RuntimeError(f"Gitea returned HTTP {error.code}") from None
|
||||
|
||||
def verdict(output: str, head: str) -> str:
|
||||
reviewed = re.findall(r"(?m)^REVIEWED_HEAD:\s*([0-9a-f]{40})\s*$", output)
|
||||
decisions = re.findall(r"(?m)^REVIEW_VERDICT:\s*(APPROVED|CHANGES_REQUIRED)\s*$", output)
|
||||
if reviewed != [head] or len(decisions) != 1:
|
||||
raise RuntimeError("Claude did not return one verdict for the exact reviewed head")
|
||||
return decisions[0]
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("worktree", type=Path)
|
||||
parser.add_argument("base_ref")
|
||||
parser.add_argument("head_ref")
|
||||
parser.add_argument("prompt_file", type=Path)
|
||||
parser.add_argument("pr", type=int)
|
||||
parser.add_argument("output_file", type=Path)
|
||||
args = parser.parse_args()
|
||||
if not args.worktree.is_dir() or not args.prompt_file.is_file() or args.pr < 1:
|
||||
parser.error("valid worktree, prompt file, and PR number required")
|
||||
if not args.output_file.resolve().is_relative_to(Path("/tmp/opencode").resolve()):
|
||||
parser.error("review output must be under /tmp/opencode")
|
||||
base = git(args.worktree, "rev-parse", "--verify", args.base_ref + "^{commit}")
|
||||
head = git(args.worktree, "rev-parse", "--verify", args.head_ref + "^{commit}")
|
||||
if not SHA.fullmatch(head) or git(args.worktree, "rev-parse", "HEAD") != head:
|
||||
raise RuntimeError("worktree HEAD differs from requested head")
|
||||
subprocess.run(["git", "-C", str(args.worktree), "merge-base", "--is-ancestor", base, head], check=True)
|
||||
prompt = args.prompt_file.read_text() + (
|
||||
f"\n\nReview only the delta from base {base} to exact head {head}. "
|
||||
"Verify current HEAD before issuing a verdict. Read only; do not modify files. "
|
||||
"End with two plain lines: REVIEWED_HEAD: <full 40-character SHA> "
|
||||
"and REVIEW_VERDICT: APPROVED or REVIEW_VERDICT: CHANGES_REQUIRED."
|
||||
)
|
||||
result = subprocess.run(
|
||||
[CLAUDE, "-p", prompt, "--model", "claude-sonnet-5", "--effort", "high",
|
||||
"--permission-mode", "dontAsk", "--disallowedTools", "Edit", "Write",
|
||||
"NotebookEdit", "--allowedTools", "Read", "Grep", "Glob",
|
||||
"Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)", "Bash(git status:*)"],
|
||||
cwd=args.worktree, text=True, capture_output=True, timeout=1200,
|
||||
)
|
||||
args.output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output_file.write_text(result.stdout)
|
||||
os.chmod(args.output_file, 0o600)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"Claude exited {result.returncode}: {result.stderr[-300:]}")
|
||||
decision = verdict(result.stdout, head)
|
||||
if git(args.worktree, "rev-parse", "HEAD") != head:
|
||||
raise RuntimeError("worktree HEAD changed during review")
|
||||
print(result.stdout, end="")
|
||||
if decision != "APPROVED":
|
||||
return 2
|
||||
credential = token()
|
||||
if api("/user", credential).get("login") != "review_agent":
|
||||
raise RuntimeError("review credential belongs to wrong account")
|
||||
pr = api(f"{REPO}/pulls/{args.pr}", credential)
|
||||
if pr.get("state") != "open" or (pr.get("head") or {}).get("sha") != head:
|
||||
raise RuntimeError("PR head changed before review publication")
|
||||
published = api(
|
||||
f"{REPO}/pulls/{args.pr}/reviews", credential,
|
||||
{"body": "Independent Claude Sonnet 5 review completed for this exact commit.",
|
||||
"commit_id": head, "event": "APPROVED"},
|
||||
)
|
||||
if published.get("state") != "APPROVED" or published.get("commit_id") != head:
|
||||
raise RuntimeError("Gitea did not confirm exact-head approval")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError,
|
||||
subprocess.TimeoutExpired) as error:
|
||||
print(f"review gate failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a project-specific controller prompt from the reusable template."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
TEMPLATE = Path(__file__).resolve().parent.parent / "templates/ORCHESTRATOR-PROMPT.template.md"
|
||||
PLACEHOLDER = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}")
|
||||
|
||||
|
||||
def render(template: str, profile: dict[str, str]) -> str:
|
||||
keys = set(PLACEHOLDER.findall(template))
|
||||
missing = sorted(keys - profile.keys())
|
||||
if missing:
|
||||
raise ValueError("missing profile values: " + ", ".join(missing))
|
||||
for key in keys:
|
||||
if not isinstance(profile[key], str) or not profile[key].strip():
|
||||
raise ValueError(f"{key} must be a nonempty string")
|
||||
result = PLACEHOLDER.sub(lambda match: profile[match.group(1)], template)
|
||||
if PLACEHOLDER.search(result):
|
||||
raise ValueError("profile values must not contain template placeholders")
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("profile", type=Path)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
profile = json.loads(args.profile.read_text())
|
||||
if not isinstance(profile, dict):
|
||||
parser.error("profile must be a JSON object")
|
||||
content = render(TEMPLATE.read_text(), profile)
|
||||
if args.output:
|
||||
args.output.write_text(content)
|
||||
print(args.output)
|
||||
else:
|
||||
sys.stdout.write(content)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
# Reusing the development cycle prompt
|
||||
|
||||
`ORCHESTRATOR-PROMPT.template.md` is the current NestQuest controller
|
||||
prompt with project-specific names, models, branches, identities, and launcher
|
||||
paths replaced by placeholders. `nestquest.profile.json` is the working
|
||||
example. Render a concrete prompt with:
|
||||
|
||||
```bash
|
||||
scripts/render-cycle-prompt templates/nestquest.profile.json --output /tmp/opencode/nestquest-prompt.md
|
||||
```
|
||||
|
||||
For another project, copy the profile and set its project name, tracker,
|
||||
context store, models, branch names, release owner, example IDs, review
|
||||
account, and installed launcher paths. Review the rendered result section by
|
||||
section, especially task/feature semantics, release rules, permissions,
|
||||
review gates, and the host runtime section. The template assumes an OpenCode
|
||||
controller, a feature/task tracker, a versioned context store, a code host,
|
||||
and a separate reviewer and approver. Adapt those sections when the project's
|
||||
systems differ. Section 27 is a single-session foreground loop suitable for
|
||||
`opencode --agent ... --prompt ...`; the reusable prompt has no service
|
||||
runtime.
|
||||
|
||||
For NestQuest, `nq-orchestrate --print-prompt` emits the fully rendered
|
||||
foreground prompt without starting OpenCode. `nq-orchestrate [mission.md]`
|
||||
renders that same prompt, appends the optional mission, and supplies it to the
|
||||
foreground orchestrator agent.
|
||||
|
||||
Publish the reviewed rendered prompt into that project's context store with
|
||||
its version check.
|
||||
Give the project its own agent definitions, broker, profile, and terminal
|
||||
launcher. The template itself does not start an orchestrator or register
|
||||
another project.
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"LAST_UPDATED": "2026-09-22",
|
||||
"PROJECT_CONTEXT_LABEL": "NESTQUEST HOUSE CHAOS COORDINATION",
|
||||
"PROJECT_NAME": "NestQuest",
|
||||
"WORK_TRACKER": "Maestro",
|
||||
"CONTEXT_STORE": "CTXD",
|
||||
"MODEL_PROVIDER": "OpenRouter",
|
||||
"CONTROLLER_MODEL": "deepseek/deepseek-v4.1-flash",
|
||||
"DEVELOPER_MODEL": "z-ai/glm-5.3-flash",
|
||||
"REVIEWER_MODEL": "claude-sonnet-5",
|
||||
"REVIEWER_DISPLAY_NAME": "Claude Sonnet 5",
|
||||
"REVIEWER_HARNESS": "Claude Code",
|
||||
"REVIEWER_VENDOR": "Claude",
|
||||
"REVIEWER_CLI": "claude",
|
||||
"MAX_DEVELOPERS": "2",
|
||||
"MAX_DEVELOPERS_WORD": "two",
|
||||
"INTEGRATION_BRANCH": "dev",
|
||||
"RELEASE_BRANCH": "main",
|
||||
"RELEASE_OWNER_NAME": "Joshua",
|
||||
"HOST_ACCOUNT": "overseer",
|
||||
"CODE_HOST": "Gitea",
|
||||
"TASK_ID_EXAMPLE": "TASK-123",
|
||||
"FEATURE_BRANCH_EXAMPLE": "feature/NQ-17-family-calendar",
|
||||
"OPENCODE_VERSION": "1.18.32",
|
||||
"PROJECT_ROOT": "/mnt/ai-storage/projects/NestQuest",
|
||||
"CONTROL_HOME": "/home/overseer/.local/share/nestquest-controller",
|
||||
"SERVICE_NAME": "nestquest-controller.service",
|
||||
"TERMINAL_LAUNCHER": "nq-orchestrate",
|
||||
"ROLE_LAUNCHER": "nq-agent",
|
||||
"REVIEW_LAUNCHER": "nq-review",
|
||||
"MERGE_BROKER": "nq-approve-merge",
|
||||
"SCRATCH_DIR": "/tmp/opencode",
|
||||
"REVIEW_ACCOUNT": "review_agent",
|
||||
"TICK_TIMEOUT_LABEL": "six-hour"
|
||||
}
|
||||
+43
-284
@@ -22,22 +22,19 @@ pytest -q``); it is never meant to run against a real HA install.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib.util
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unicodedata
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
from html.entities import name2codepoint
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from unidecode import unidecode as _unidecode
|
||||
|
||||
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for
|
||||
|
||||
@@ -50,13 +47,11 @@ _HA_MODULES = (
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.data_entry_flow",
|
||||
"homeassistant.exceptions",
|
||||
"homeassistant.util",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.config_validation",
|
||||
"homeassistant.helpers.entity",
|
||||
"homeassistant.helpers.event",
|
||||
"homeassistant.helpers.update_coordinator",
|
||||
"homeassistant.helpers.aiohttp_client",
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.sensor",
|
||||
"homeassistant.components.binary_sensor",
|
||||
@@ -99,13 +94,11 @@ for _parent, _child in (
|
||||
("homeassistant", "data_entry_flow"),
|
||||
("homeassistant", "core"),
|
||||
("homeassistant", "exceptions"),
|
||||
("homeassistant", "util"),
|
||||
("homeassistant", "helpers"),
|
||||
("homeassistant.helpers", "config_validation"),
|
||||
("homeassistant.helpers", "entity"),
|
||||
("homeassistant.helpers", "event"),
|
||||
("homeassistant.helpers", "update_coordinator"),
|
||||
("homeassistant.helpers", "aiohttp_client"),
|
||||
("homeassistant", "components"),
|
||||
("homeassistant.components", "sensor"),
|
||||
("homeassistant.components", "binary_sensor"),
|
||||
@@ -224,68 +217,6 @@ _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||
_exceptions_mock.Unauthorized = Unauthorized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# homeassistant.util.slugify: a faithful stand-in for the real slugifier.
|
||||
#
|
||||
# Real HA (2024.6) wraps python-slugify's frozen legacy pipeline and answers
|
||||
# its "unknown" sentinel when a non-empty name slugs to nothing. The stub
|
||||
# mirrors that pipeline step for step — quotes to dashes, NFKD-normalize,
|
||||
# TRANSLITERATE through unidecode (the backend python-slugify's "auto" mode
|
||||
# prefers, and a dev dependency here), decode HTML entities, NFKD again,
|
||||
# lowercase, drop quotes, fold runs of non-[a-z0-9-] to one dash, collapse
|
||||
# and strip dashes, then map dashes to the requested separator — and was
|
||||
# verified byte-for-byte against real python-slugify across Latin, Cyrillic,
|
||||
# CJK and Greek household-name shapes. Transliteration is the point: HA
|
||||
# derives the per-child entity ids from a name like "京子" as
|
||||
# "nestquest_jing_zi_…", so the roster's slugs must transliterate too,
|
||||
# never dropping characters into "unknown". Deliberately NOT truncating:
|
||||
# real slugify caps nothing; the entity registry truncates the full
|
||||
# entity_id instead.
|
||||
# ---------------------------------------------------------------------------
|
||||
_HA_QUOTE_PATTERN = re.compile(r"[']+")
|
||||
_HA_CHAR_ENTITY_PATTERN = re.compile(r"&(%s);" % "|".join(name2codepoint))
|
||||
_HA_DECIMAL_PATTERN = re.compile(r"&#(\d+);")
|
||||
_HA_HEX_PATTERN = re.compile(r"&#x([\da-fA-F]+);")
|
||||
_HA_NUMBERS_PATTERN = re.compile(r"(?<=\d),(?=\d)")
|
||||
_HA_DISALLOWED_CHARS_PATTERN = re.compile(r"[^-a-zA-Z0-9]+")
|
||||
_HA_DUPLICATE_DASH_PATTERN = re.compile(r"-{2,}")
|
||||
|
||||
|
||||
def _ha_slugify(text: str | None, *, separator: str = "_") -> str:
|
||||
"""Faithful homeassistant.util.slugify stand-in (see section above)."""
|
||||
if text == "" or text is None:
|
||||
return ""
|
||||
text = str(text)
|
||||
text = _HA_QUOTE_PATTERN.sub("-", text)
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = _unidecode(text)
|
||||
text = _HA_CHAR_ENTITY_PATTERN.sub(
|
||||
lambda match: chr(name2codepoint[match.group(1)]), text
|
||||
)
|
||||
try:
|
||||
text = _HA_DECIMAL_PATTERN.sub(
|
||||
lambda match: chr(int(match.group(1))), text
|
||||
)
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
try:
|
||||
text = _HA_HEX_PATTERN.sub(
|
||||
lambda match: chr(int(match.group(1), 16)), text
|
||||
)
|
||||
except (ValueError, OverflowError):
|
||||
pass
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = text.lower()
|
||||
text = _HA_QUOTE_PATTERN.sub("", text)
|
||||
text = _HA_NUMBERS_PATTERN.sub("", text)
|
||||
text = _HA_DISALLOWED_CHARS_PATTERN.sub("-", text)
|
||||
text = _HA_DUPLICATE_DASH_PATTERN.sub("-", text).strip("-")
|
||||
return "unknown" if text == "" else text.replace("-", separator)
|
||||
|
||||
|
||||
_ha_mock("homeassistant.util").slugify = _ha_slugify
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
|
||||
@@ -434,15 +365,6 @@ class BinarySensorEntity(Entity):
|
||||
return self._attr_device_class
|
||||
|
||||
|
||||
class UpdateFailed(HomeAssistantError):
|
||||
"""Stand-in mirroring homeassistant.helpers.update_coordinator.UpdateFailed.
|
||||
|
||||
The exception an update pass raises to report a failed fetch: the
|
||||
coordinator records ``last_update_success = False`` instead of
|
||||
propagating it out of the refresh.
|
||||
"""
|
||||
|
||||
|
||||
class DataUpdateCoordinator:
|
||||
"""Functional stand-in for HA's DataUpdateCoordinator.
|
||||
|
||||
@@ -451,20 +373,8 @@ class DataUpdateCoordinator:
|
||||
``async_add_listener``/``async_update_listeners`` for entity
|
||||
subscription, ``async_refresh``/``async_config_entry_first_refresh``
|
||||
driving the subclass's ``_async_update_data``, ``last_update_success``,
|
||||
``last_exception``, and ``data``. Interval scheduling is NOT
|
||||
simulated (no real clock ticks in tests) — tests drive refreshes
|
||||
directly.
|
||||
|
||||
The failure contract mirrors real HA exactly (it is the trap this
|
||||
harness once hid): an update failure NEVER propagates out of
|
||||
``async_refresh`` — the coordinator records
|
||||
``last_update_success = False``, keeps its last good ``data``, and
|
||||
still notifies listeners so entities re-render unavailable — and
|
||||
``async_config_entry_first_refresh`` wraps ANY failed pass in
|
||||
:class:`ConfigEntryNotReady`, with the original error only
|
||||
surviving as ``__cause__``. A stand-in that re-raised the raw
|
||||
update error would let setup code key on the original exception
|
||||
type — a clause real HA can never reach.
|
||||
and ``data``. Interval scheduling is NOT simulated (no real clock
|
||||
ticks in tests) — tests drive refreshes directly.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, logger, *, name=None, update_interval=None):
|
||||
@@ -474,7 +384,6 @@ class DataUpdateCoordinator:
|
||||
self.update_interval = update_interval
|
||||
self.data = None
|
||||
self.last_update_success = False
|
||||
self.last_exception = None
|
||||
self._listeners = []
|
||||
|
||||
def async_add_listener(self, update_callback, context=None):
|
||||
@@ -494,35 +403,18 @@ class DataUpdateCoordinator:
|
||||
callback()
|
||||
|
||||
async def async_refresh(self):
|
||||
"""Run one update pass and notify listeners.
|
||||
|
||||
Mirrors HA: a failed update pass is RECORDED, never raised —
|
||||
``last_update_success`` goes False, the exception lands on
|
||||
``last_exception``, and listeners still fire; only cancellation
|
||||
propagates.
|
||||
"""
|
||||
"""Run one update pass and notify listeners."""
|
||||
try:
|
||||
self.data = await self._async_update_data()
|
||||
self.last_update_success = True
|
||||
except Exception as err:
|
||||
except BaseException:
|
||||
self.last_update_success = False
|
||||
self.last_exception = err
|
||||
raise
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_config_entry_first_refresh(self):
|
||||
"""First refresh at config-entry setup, per HA's real contract.
|
||||
|
||||
HA's real ``async_config_entry_first_refresh`` wraps ANY failed
|
||||
update pass in ``ConfigEntryNotReady`` — the original error
|
||||
survives only as ``__cause__`` — so setup code that must
|
||||
tolerate a failed first refresh keys on ConfigEntryNotReady,
|
||||
never on the original exception type.
|
||||
"""
|
||||
"""First refresh at config-entry setup (alias of refresh)."""
|
||||
await self.async_refresh()
|
||||
if not self.last_update_success:
|
||||
raise ConfigEntryNotReady(
|
||||
f"error fetching {self.name} data: {self.last_exception}"
|
||||
) from self.last_exception
|
||||
|
||||
async def async_shutdown(self):
|
||||
"""Release listeners on teardown."""
|
||||
@@ -532,7 +424,6 @@ class DataUpdateCoordinator:
|
||||
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
||||
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
||||
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
|
||||
_update_coordinator_mock.UpdateFailed = UpdateFailed
|
||||
|
||||
_entity_mock = _ha_mock("homeassistant.helpers.entity")
|
||||
_entity_mock.DeviceInfo = DeviceInfo
|
||||
@@ -809,174 +700,6 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry):
|
||||
return entry
|
||||
|
||||
|
||||
class StubSnapshotClient:
|
||||
"""A no-network stand-in for the API client's snapshot surface.
|
||||
|
||||
Answers ``get_snapshot()`` from a scripted queue of outcomes — a
|
||||
payload dict or an exception per call, consumed in order — and
|
||||
records every call. When the queue is exhausted it re-serves the
|
||||
last outcome, so setup's first refresh and a test's explicit
|
||||
refreshes poll the same scripted state. Register it (or any
|
||||
scripted client) with :func:`set_coordinator_client` BEFORE setup.
|
||||
"""
|
||||
|
||||
def __init__(self, *outcomes) -> None:
|
||||
self.calls = 0
|
||||
self._outcomes = list(outcomes)
|
||||
self._last = outcomes[-1] if outcomes else None
|
||||
#: The SSE frames the stand-in "published" (the API service's
|
||||
# stream content), drained by :func:`refire_api_transitions`.
|
||||
self.published_frames = []
|
||||
|
||||
async def get_snapshot(self):
|
||||
self.calls += 1
|
||||
if not self._outcomes:
|
||||
# Re-serve the last outcome: setup's first refresh and the
|
||||
# test's explicit pass both poll the same scripted state.
|
||||
outcome = self._last
|
||||
else:
|
||||
outcome = self._outcomes.pop(0)
|
||||
self._last = outcome
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
class ScriptedPanelClient(StubSnapshotClient):
|
||||
"""Snapshot + completion stand-in for the API service's panel plane.
|
||||
|
||||
Serves the scripted snapshot outcomes (like
|
||||
:class:`StubSnapshotClient`), records ``complete_instance`` calls
|
||||
the way the panel route receives them, optionally fails them the
|
||||
way the client types the failure, and carries the SSE frames the
|
||||
API "published" in ``published_frames`` for
|
||||
:func:`refire_api_transitions` to deliver. Everything the
|
||||
integration reads over the wire, no network.
|
||||
"""
|
||||
|
||||
def __init__(self, *outcomes) -> None:
|
||||
super().__init__(*outcomes)
|
||||
self.complete_calls: list[tuple[int, int]] = []
|
||||
self.published_frames: list[tuple[str, dict]] = []
|
||||
#: When set, the next ``complete_instance`` raises this instead
|
||||
# of succeeding (a typed NestQuestApiError in production).
|
||||
self.fail_complete_with: BaseException | None = None
|
||||
|
||||
async def complete_instance(
|
||||
self, instance_id: int, actor_child_id: int
|
||||
) -> dict:
|
||||
self.complete_calls.append((instance_id, actor_child_id))
|
||||
if self.fail_complete_with is not None:
|
||||
raise self.fail_complete_with
|
||||
return {"status": "done"}
|
||||
|
||||
|
||||
def set_coordinator_client(entry, client):
|
||||
"""Register a scripted client for ``entry``'s coordinator factory."""
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
coordinator_module._COORDINATOR_CLIENT_OVERRIDES[entry.entry_id] = client
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def coordinator_client_from_test_override():
|
||||
"""Resolve every entry's coordinator client through the test seam.
|
||||
|
||||
The integration's setup builds its API client through
|
||||
``coordinator_client_from_entry``; this fixture swaps that factory
|
||||
for a test one handing out the client registered with
|
||||
:func:`set_coordinator_client`, or — for an entry with no scripted
|
||||
client — a stub serving an EMPTY but valid household snapshot (the
|
||||
shape the API's panel route returns before any child exists; the
|
||||
real client would need aiohttp, which this mock-only harness does
|
||||
not provide).
|
||||
"""
|
||||
import datetime
|
||||
import unittest.mock as mock
|
||||
|
||||
import custom_components.nestquest as nq_mod
|
||||
import custom_components.nestquest.coordinator as coordinator_module
|
||||
|
||||
_COORDINATOR_CLIENT_OVERRIDES = (
|
||||
coordinator_module._COORDINATOR_CLIENT_OVERRIDES
|
||||
)
|
||||
|
||||
def _factory(hass, entry, **kwargs):
|
||||
override = _COORDINATOR_CLIENT_OVERRIDES.get(entry.entry_id)
|
||||
if override is not None:
|
||||
return override
|
||||
return StubSnapshotClient(
|
||||
{
|
||||
"today_iso": datetime.date.today().isoformat(),
|
||||
"cycle_day": 0,
|
||||
"children": [],
|
||||
}
|
||||
)
|
||||
|
||||
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
||||
patcher = mock.patch.object(
|
||||
nq_mod, "coordinator_client_from_entry", _factory
|
||||
)
|
||||
patcher.start()
|
||||
yield
|
||||
patcher.stop()
|
||||
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
||||
|
||||
|
||||
async def refire_api_transitions(hass, entry, client):
|
||||
"""Deliver the SSE frames the stand-in client has published so far
|
||||
through the integration's SSE subscription — the production event
|
||||
path.
|
||||
|
||||
Since Feature 18 the ``complete_quest`` service fires no local
|
||||
bus event: the API service builds and publishes the transition
|
||||
frames on its SSE stream, and the integration's subscription
|
||||
(:mod:`custom_components.nestquest.sse`) re-fires each frame on
|
||||
the bus. This helper streams the frames the stand-in client
|
||||
published (``client.published_frames``) through a real
|
||||
:class:`~custom_components.nestquest.sse.NestQuestEventStream`
|
||||
and waits (bounded) until the bus has re-fired every one of them.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from custom_components.nestquest.sse import NestQuestEventStream
|
||||
|
||||
frames, client.published_frames = client.published_frames, []
|
||||
|
||||
class _ScriptedStream:
|
||||
"""A healthy stream: the frames, then it stays open."""
|
||||
|
||||
def __init__(self, frames) -> None:
|
||||
self._frames = frames
|
||||
|
||||
def stream_events(self):
|
||||
async def _gen():
|
||||
for frame in self._frames:
|
||||
yield frame
|
||||
await asyncio.Event().wait()
|
||||
|
||||
return _gen()
|
||||
|
||||
before = {etype: len(hass.bus.fired(etype)) for etype, _ in frames}
|
||||
expected = len(frames)
|
||||
manager = NestQuestEventStream(hass, _ScriptedStream(frames))
|
||||
manager.start()
|
||||
try:
|
||||
async with asyncio.timeout(2):
|
||||
while True:
|
||||
delivered = sum(
|
||||
len(hass.bus.fired(etype)) - before[etype]
|
||||
for etype, _ in frames
|
||||
)
|
||||
if delivered >= expected:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
await manager.stop()
|
||||
return frames
|
||||
|
||||
|
||||
class _HassNamespace(SimpleNamespace):
|
||||
"""SimpleNamespace subclass so the hass stub can hold weak references."""
|
||||
|
||||
@@ -1200,6 +923,42 @@ def make_config_flow_entry():
|
||||
return _factory
|
||||
|
||||
|
||||
def freeze_nestquest_clock(
|
||||
monkeypatch, instant: datetime.datetime
|
||||
) -> tuple[datetime.datetime, list]:
|
||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||
|
||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched,
|
||||
so the generation path's ``datetime.datetime.now`` reads this instant
|
||||
(localized to whatever time zone it asks for) while every other module
|
||||
and the test harness keep the real clock. Returns ``(frozen, requested)``
|
||||
where ``frozen`` is the instant normalized to UTC and ``requested`` is
|
||||
the list of ``tzinfo`` objects the code under test passed to ``now`` —
|
||||
so a caller can prove the generation path actually requested the
|
||||
configured HA time zone rather than silently reading UTC.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
frozen = instant.astimezone(datetime.timezone.utc)
|
||||
requested: list = []
|
||||
|
||||
class _FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
requested.append(tz)
|
||||
if tz is None:
|
||||
return frozen.replace(tzinfo=None)
|
||||
return frozen.astimezone(tz)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
datetime=_FrozenDatetime,
|
||||
timedelta=datetime.timedelta,
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin-plane API fixtures (shared by the admin-plane test modules).
|
||||
#
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""Tests for the Feature 03 admin allowlist business layer (core.admin_allowlist)."""
|
||||
"""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.core.admin_allowlist import (
|
||||
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.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
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):
|
||||
@@ -190,6 +193,165 @@ def test_seed_rejects_malformed_candidates(tmp_path) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 —
|
||||
@@ -215,13 +377,54 @@ def test_seed_owner_ids_argument(tmp_path) -> None:
|
||||
_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.core.admin_allowlist import is_admin
|
||||
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
|
||||
@@ -244,7 +447,7 @@ def test_is_admin_verdict_matrix(tmp_path) -> None:
|
||||
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.core.admin_allowlist import is_admin
|
||||
from custom_components.nestquest.admin_allowlist import is_admin
|
||||
|
||||
for candidate in ("user-owner", "", None, 1):
|
||||
assert await is_admin(database, candidate) is False
|
||||
@@ -257,7 +460,7 @@ 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.core.admin_allowlist import is_admin
|
||||
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"):
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
"""Admin-plane occurrence-preview route tests (task 5f843564).
|
||||
|
||||
These tests exercise ``POST /api/v1/admin/quest-definitions/occurrences-preview``
|
||||
against the ASGI app with httpx, reusing the shared local-RSA-key/
|
||||
stubbed-JWKS harness (:mod:`tests.admin_jwt_harness`) so an admin JWT
|
||||
genuinely authenticates with NO network access.
|
||||
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: no credential is 401, the panel service token is 403
|
||||
(refused as a credential type), a JWT without the admin group is
|
||||
403, an admin JWT is 200.
|
||||
- The returned dates are asserted EXACTLY for a weekly rule with an
|
||||
interval and a start_date, a daily rule with an interval, a rule
|
||||
with an end_date (the list stops at it), a month-end-clamped monthly
|
||||
rule, and the default start (the pinned host clock's today); one
|
||||
case also asserts parity with the core's own ``occurrences_between``
|
||||
over the documented window.
|
||||
- A rejected rule (the same core ``from_dict`` validation create/edit
|
||||
use), a non-strict ``start_date`` and a ``count`` outside 1..50 are
|
||||
422.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from api import routes_admin
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The recurrence engine through the SAME core copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
||||
_recurrence = importlib.import_module("nestquest_core.recurrence")
|
||||
|
||||
PREVIEW_URL = "/api/v1/admin/quest-definitions/occurrences-preview"
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
#: The 403 detail for the panel service token on an admin route.
|
||||
PANEL_TOKEN_REJECTION_DETAIL = (
|
||||
"This credential type is not accepted for admin access"
|
||||
)
|
||||
|
||||
#: The 403 detail for a valid JWT without the admin group.
|
||||
FORBIDDEN_DETAIL = (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
#: The pinned host instant: Wednesday 2026-09-23, noon UTC.
|
||||
PINNED_NOW = datetime.datetime(2026, 9, 23, 12, 0, 0, tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _weekly_rule() -> dict[str, object]:
|
||||
"""Every OTHER week's Tuesday and Friday, anchored on Tue 2026-03-03."""
|
||||
return {
|
||||
"rule_type": "weekly",
|
||||
"interval": 2,
|
||||
"weekday_set": [1, 4],
|
||||
"start_date": "2026-03-03",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def preview(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""An admin-plane app client with the host clock pinned (and counted)."""
|
||||
clock_reads: list[datetime.datetime] = []
|
||||
|
||||
def _admin_now() -> datetime.datetime:
|
||||
clock_reads.append(PINNED_NOW)
|
||||
return PINNED_NOW
|
||||
|
||||
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(client=client, clock_reads=clock_reads)
|
||||
|
||||
|
||||
async def _post(client: object, body: dict[str, object]) -> object:
|
||||
"""POST a preview body with an admin JWT."""
|
||||
return await client.post(PREVIEW_URL, json=body, headers=_admin_headers())
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency ----------------------
|
||||
|
||||
|
||||
async def test_preview_without_credential_is_401(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""No Authorization header is the uniform 401."""
|
||||
response = await preview.client.post(
|
||||
PREVIEW_URL, json={"rule": _weekly_rule()}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_preview_refuses_panel_service_token(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The panel service token is refused as a credential type (403)."""
|
||||
response = await preview.client.post(
|
||||
PREVIEW_URL,
|
||||
json={"rule": _weekly_rule()},
|
||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_preview_refuses_non_admin_jwt(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A valid JWT without the nestquest-admins group is 403."""
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await preview.client.post(
|
||||
PREVIEW_URL,
|
||||
json={"rule": _weekly_rule()},
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_preview_admin_jwt_is_200(preview: SimpleNamespace) -> None:
|
||||
"""An admin JWT is 200 with the documented ``dates`` shape."""
|
||||
response = await _post(
|
||||
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert set(response.json()) == {"dates"}
|
||||
|
||||
|
||||
# --- the returned dates ----------------------------------------------------
|
||||
|
||||
|
||||
async def test_weekly_interval_rule_from_before_its_start(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Every other week's Tue/Fri, starting at the rule's own start_date.
|
||||
|
||||
The preview starts on Sun 2026-03-01, before the rule's anchor, so
|
||||
the first date is the anchor itself; the off weeks (Mar 10-16,
|
||||
Mar 24-30) are skipped.
|
||||
"""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": 6},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == [
|
||||
"2026-03-03",
|
||||
"2026-03-06",
|
||||
"2026-03-17",
|
||||
"2026-03-20",
|
||||
"2026-03-31",
|
||||
"2026-04-03",
|
||||
]
|
||||
|
||||
|
||||
async def test_weekly_interval_rule_from_mid_cadence(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A start inside an on-week keeps the rule's anchor cadence."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{"rule": _weekly_rule(), "start_date": "2026-03-18", "count": 4},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == [
|
||||
"2026-03-20",
|
||||
"2026-03-31",
|
||||
"2026-04-03",
|
||||
"2026-04-14",
|
||||
]
|
||||
|
||||
|
||||
async def test_daily_interval_rule(preview: SimpleNamespace) -> None:
|
||||
"""Every third day from 2026-01-01, previewed from 2026-01-05."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 3,
|
||||
"start_date": "2026-01-01",
|
||||
},
|
||||
"start_date": "2026-01-05",
|
||||
"count": 4,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == [
|
||||
"2026-01-07",
|
||||
"2026-01-10",
|
||||
"2026-01-13",
|
||||
"2026-01-16",
|
||||
]
|
||||
|
||||
|
||||
async def test_rule_with_end_date_stops_at_it(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A daily rule ending 2026-05-04 yields only its four dates."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"start_date": "2026-05-01",
|
||||
"end_date": "2026-05-04",
|
||||
},
|
||||
"start_date": "2026-04-28",
|
||||
"count": 10,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == [
|
||||
"2026-05-01",
|
||||
"2026-05-02",
|
||||
"2026-05-03",
|
||||
"2026-05-04",
|
||||
]
|
||||
|
||||
|
||||
async def test_monthly_day_rule_clamps_to_month_end(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The core's month-end policy: the 31st fires on Feb 28th."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {
|
||||
"rule_type": "monthly_day",
|
||||
"day_of_month": 31,
|
||||
"start_date": "2026-01-31",
|
||||
},
|
||||
"start_date": "2026-01-01",
|
||||
"count": 3,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == [
|
||||
"2026-01-31",
|
||||
"2026-02-28",
|
||||
"2026-03-31",
|
||||
]
|
||||
|
||||
|
||||
async def test_default_start_is_host_today_with_default_count(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""No start_date: ONE clock read's date; no count: ten dates."""
|
||||
response = await _post(preview.client, {"rule": {"rule_type": "daily"}})
|
||||
assert response.status_code == 200
|
||||
expected = [
|
||||
(datetime.date(2026, 9, 23) + datetime.timedelta(days=n)).isoformat()
|
||||
for n in range(10)
|
||||
]
|
||||
assert response.json()["dates"] == expected
|
||||
assert preview.clock_reads == [PINNED_NOW]
|
||||
|
||||
|
||||
async def test_supplied_start_date_reads_no_clock(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A supplied start_date is used as-is: the host clock is not read."""
|
||||
response = await _post(
|
||||
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert preview.clock_reads == []
|
||||
|
||||
|
||||
async def test_dates_match_core_occurrences_between_over_window(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The route's dates ARE the core's walk over the 366-day window."""
|
||||
rule_body = {
|
||||
"rule_type": "monthly_weekday",
|
||||
"nth_weekday": -1,
|
||||
"nth_weekday_weekday": 4,
|
||||
"start_date": "2026-01-01",
|
||||
}
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{"rule": rule_body, "start_date": "2026-02-10", "count": 50},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
core_dates = _recurrence.occurrences_between(
|
||||
_recurrence.ScheduleRule.from_dict(rule_body),
|
||||
"2026-02-10",
|
||||
"2027-02-11",
|
||||
)
|
||||
assert response.json()["dates"] == core_dates
|
||||
assert len(core_dates) == 12
|
||||
|
||||
|
||||
async def test_yearly_rule_shows_next_occurrence_within_window(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The 366-day window always reaches a yearly rule's next date."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {
|
||||
"rule_type": "yearly",
|
||||
"month": 9,
|
||||
"day_of_month": 22,
|
||||
"start_date": "2020-09-22",
|
||||
},
|
||||
"start_date": "2026-09-23",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == ["2027-09-22"]
|
||||
|
||||
|
||||
async def test_start_near_calendar_end_is_422(
|
||||
preview: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A window that would reach 9999-12-31 is rejected, not clamped."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{"rule": {"rule_type": "daily"}, "start_date": "9999-12-30"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "too close to the end of the calendar" in response.json()["detail"]
|
||||
|
||||
|
||||
async def test_latest_accepted_start_date(preview: SimpleNamespace) -> None:
|
||||
"""The last start whose window ends before 9999-12-31 is accepted."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {"rule_type": "daily"},
|
||||
"start_date": "9998-12-29",
|
||||
"count": 1,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dates"] == ["9998-12-29"]
|
||||
|
||||
|
||||
# --- rejections: 422 -------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rule",
|
||||
[
|
||||
{"rule_type": "weekly"}, # weekly without weekday_set
|
||||
{"rule_type": "fortnightly"}, # unknown rule_type
|
||||
{"rule_type": "daily", "interval": 0},
|
||||
{"rule_type": "daily", "weekday_set": [1]}, # forbidden field
|
||||
{"rule_type": "daily", "start_date": "2026-3-1"},
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": "2026-05-04",
|
||||
"end_date": "2026-05-01",
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_malformed_rule_is_422(
|
||||
preview: SimpleNamespace, rule: dict[str, object]
|
||||
) -> None:
|
||||
"""A rule the core's from_dict rejects is 422, as on create/edit."""
|
||||
response = await _post(
|
||||
preview.client, {"rule": rule, "start_date": "2026-03-01"}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_missing_rule_is_422(preview: SimpleNamespace) -> None:
|
||||
"""The rule is required: the body model's 422."""
|
||||
response = await _post(preview.client, {"start_date": "2026-03-01"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start_date", ["2026-3-1", "20260301", "tomorrow", "2026-02-30", ""]
|
||||
)
|
||||
async def test_non_strict_start_date_is_422(
|
||||
preview: SimpleNamespace, start_date: str
|
||||
) -> None:
|
||||
"""start_date must be a strict YYYY-MM-DD date (the core's check)."""
|
||||
response = await _post(
|
||||
preview.client, {"rule": _weekly_rule(), "start_date": start_date}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [0, -1, 51, 1000, True, "5", 2.5])
|
||||
async def test_count_out_of_range_is_422(
|
||||
preview: SimpleNamespace, count: object
|
||||
) -> None:
|
||||
"""count must be a strict JSON integer in 1..50."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": count},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 50])
|
||||
async def test_count_bounds_are_inclusive(
|
||||
preview: SimpleNamespace, count: int
|
||||
) -> None:
|
||||
"""count 1 and 50 are accepted and truncate the list exactly."""
|
||||
response = await _post(
|
||||
preview.client,
|
||||
{
|
||||
"rule": {"rule_type": "daily"},
|
||||
"start_date": "2026-01-01",
|
||||
"count": count,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["dates"]) == count
|
||||
@@ -1,345 +0,0 @@
|
||||
"""Admin-plane presence READ route tests (task 07fde892).
|
||||
|
||||
``GET /api/v1/admin/children/{child_id}/presence-schedule`` and
|
||||
``GET /api/v1/admin/presence-overrides`` against the ASGI app with
|
||||
httpx, through the shared stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`) — no network access.
|
||||
|
||||
- The schedule read answers ``{"schedule": {...}}`` in the PUT
|
||||
response's shape for a seeded schedule, ``{"schedule": null}`` for a
|
||||
child with none (present every day — NOT a 404), and 404 for an
|
||||
unknown child.
|
||||
- The overrides read answers ``{"overrides": [...]}`` in the POST
|
||||
response's shape, ordered by start date, child id, id; ``child_id``
|
||||
and the ``start``/``end`` range each narrow the list; a malformed or
|
||||
inverted range is 422 and an unknown ``child_id`` is 404.
|
||||
- Both routes inherit the router's ONE ``require_admin``: no
|
||||
credential 401, the panel service token 403, a non-admin JWT 403, an
|
||||
admin JWT 200.
|
||||
|
||||
Seeding goes through the admin write routes, so the reads are proven
|
||||
against rows the real write path stored.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for, make_token
|
||||
|
||||
OVERRIDES_URL = "/api/v1/admin/presence-overrides"
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
#: The 403 detail for the panel service token on an admin route.
|
||||
PANEL_TOKEN_REJECTION_DETAIL = (
|
||||
"This credential type is not accepted for admin access"
|
||||
)
|
||||
|
||||
#: The 403 detail for a valid JWT without the admin group.
|
||||
FORBIDDEN_DETAIL = (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
|
||||
def _schedule_url(child_id: int) -> str:
|
||||
return f"/api/v1/admin/children/{child_id}/presence-schedule"
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
async def _create_child(client: object, name: str) -> int:
|
||||
"""Create one child through the admin children route; return the id."""
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
async def _create_override(
|
||||
client: object,
|
||||
child_id: int,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
is_present: bool = False,
|
||||
note: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Create one override through the admin write route; return it."""
|
||||
created = await client.post(
|
||||
OVERRIDES_URL,
|
||||
json={
|
||||
"child_id": child_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"is_present": is_present,
|
||||
"note": note,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
return created.json()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def reads(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane client over a fresh database."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(client=client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded(reads: SimpleNamespace) -> SimpleNamespace:
|
||||
"""Two children with interleaved overrides (Ada has a schedule)."""
|
||||
client = reads.client
|
||||
ada = await _create_child(client, "Ada")
|
||||
ben = await _create_child(client, "Ben")
|
||||
put = await client.put(
|
||||
_schedule_url(ada),
|
||||
json={
|
||||
"cycle_length_weeks": 2,
|
||||
"anchor_date": "2026-01-05",
|
||||
"pattern": {"0": [4, 0, 2], "1": []},
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert put.status_code == 200
|
||||
ada_march = await _create_override(
|
||||
client, ada, "2026-03-01", "2026-03-07", note="grandparents"
|
||||
)
|
||||
ben_january = await _create_override(
|
||||
client, ben, "2026-01-10", "2026-01-12", is_present=True
|
||||
)
|
||||
ben_march = await _create_override(client, ben, "2026-03-01", "2026-03-01")
|
||||
ada_may = await _create_override(client, ada, "2026-05-20", "2026-05-20")
|
||||
return SimpleNamespace(
|
||||
client=client,
|
||||
ada=ada,
|
||||
ben=ben,
|
||||
ada_march=ada_march,
|
||||
ben_january=ben_january,
|
||||
ben_march=ben_march,
|
||||
ada_may=ada_may,
|
||||
)
|
||||
|
||||
|
||||
# --- presence schedule read ------------------------------------------------
|
||||
|
||||
|
||||
async def test_schedule_read_returns_the_stored_schedule(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A seeded schedule reads back in the PUT response's shape."""
|
||||
response = await seeded.client.get(
|
||||
_schedule_url(seeded.ada), headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"schedule": {
|
||||
"child_id": seeded.ada,
|
||||
"cycle_length_weeks": 2,
|
||||
"anchor_date": "2026-01-05",
|
||||
"pattern": {"0": [0, 2, 4], "1": []},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_schedule_read_without_schedule_is_null_not_404(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A child with no schedule (present every day) is schedule: null."""
|
||||
response = await seeded.client.get(
|
||||
_schedule_url(seeded.ben), headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"schedule": None}
|
||||
|
||||
|
||||
async def test_schedule_read_unknown_child_is_404(
|
||||
reads: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown child is 404 with the children routes' detail."""
|
||||
response = await reads.client.get(
|
||||
_schedule_url(999), headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Child not found"}
|
||||
|
||||
|
||||
# --- presence overrides read -----------------------------------------------
|
||||
|
||||
|
||||
async def test_overrides_read_lists_all_in_stable_order(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Unfiltered: every override, by start date, then child id, then id."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"overrides": [
|
||||
seeded.ben_january,
|
||||
seeded.ada_march,
|
||||
seeded.ben_march,
|
||||
seeded.ada_may,
|
||||
]
|
||||
}
|
||||
assert seeded.ada_march == {
|
||||
"id": seeded.ada_march["id"],
|
||||
"child_id": seeded.ada,
|
||||
"start_date": "2026-03-01",
|
||||
"end_date": "2026-03-07",
|
||||
"is_present": False,
|
||||
"note": "grandparents",
|
||||
}
|
||||
|
||||
|
||||
async def test_overrides_read_empty_household(reads: SimpleNamespace) -> None:
|
||||
"""No overrides at all is an empty list."""
|
||||
response = await reads.client.get(OVERRIDES_URL, headers=_admin_headers())
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"overrides": []}
|
||||
|
||||
|
||||
async def test_overrides_read_filters_by_child(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""child_id keeps only that child's overrides."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL,
|
||||
params={"child_id": seeded.ada},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"overrides": [seeded.ada_march, seeded.ada_may]
|
||||
}
|
||||
|
||||
|
||||
async def test_overrides_read_filters_by_overlapping_range(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""start/end keep overrides overlapping the closed range (inclusive)."""
|
||||
# 2026-03-07 is Ada's March override's LAST day: the boundary counts.
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL,
|
||||
params={"start": "2026-03-07", "end": "2026-05-20"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"overrides": [seeded.ada_march, seeded.ada_may]
|
||||
}
|
||||
|
||||
|
||||
async def test_overrides_read_open_ended_range_filters(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""start alone keeps later-ending, end alone keeps earlier-starting."""
|
||||
from_april = await seeded.client.get(
|
||||
OVERRIDES_URL, params={"start": "2026-04-01"}, headers=_admin_headers()
|
||||
)
|
||||
assert from_april.json() == {"overrides": [seeded.ada_may]}
|
||||
until_january = await seeded.client.get(
|
||||
OVERRIDES_URL, params={"end": "2026-01-31"}, headers=_admin_headers()
|
||||
)
|
||||
assert until_january.json() == {"overrides": [seeded.ben_january]}
|
||||
|
||||
|
||||
async def test_overrides_read_combines_child_and_range(
|
||||
seeded: SimpleNamespace,
|
||||
) -> None:
|
||||
"""child_id and the range narrow together."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL,
|
||||
params={"child_id": seeded.ben, "start": "2026-02-01", "end": "2026-12-31"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"overrides": [seeded.ben_march]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
{"start": "2026-03-10", "end": "2026-03-01"},
|
||||
{"start": "2026-3-1"},
|
||||
{"end": "not-a-date"},
|
||||
{"start": "2026-02-30"},
|
||||
],
|
||||
)
|
||||
async def test_overrides_read_invalid_range_is_422(
|
||||
seeded: SimpleNamespace, params: dict[str, str]
|
||||
) -> None:
|
||||
"""A malformed date or an inverted range is the core's 422."""
|
||||
response = await seeded.client.get(
|
||||
OVERRIDES_URL, params=params, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_overrides_read_unknown_child_is_404(
|
||||
reads: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown child_id filter is 404, never an empty list."""
|
||||
response = await reads.client.get(
|
||||
OVERRIDES_URL, params={"child_id": 999}, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Child not found"}
|
||||
|
||||
|
||||
# --- authentication: the router's ONE require_admin --------------------------
|
||||
|
||||
|
||||
@pytest.fixture(params=["schedule", "overrides"])
|
||||
async def read_url(request: pytest.FixtureRequest, reads: SimpleNamespace) -> str:
|
||||
"""Each read route's URL (the schedule route for a real child)."""
|
||||
if request.param == "overrides":
|
||||
return OVERRIDES_URL
|
||||
return _schedule_url(await _create_child(reads.client, "Ada"))
|
||||
|
||||
|
||||
async def test_read_without_credential_is_401(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
response = await reads.client.get(read_url)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_read_refuses_panel_service_token(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
response = await reads.client.get(
|
||||
read_url, headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_read_refuses_non_admin_jwt(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await reads.client.get(
|
||||
read_url, headers={"Authorization": f"Bearer {non_admin}"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_read_admin_jwt_is_200(
|
||||
reads: SimpleNamespace, read_url: str
|
||||
) -> None:
|
||||
response = await reads.client.get(read_url, headers=_admin_headers())
|
||||
assert response.status_code == 200
|
||||
@@ -18,9 +18,8 @@ caveat in api/nestquest_core.py):
|
||||
and windows exactly as posted.
|
||||
- ``PATCH /api/v1/admin/quest-definitions/{id}``: an edit PERSISTS the
|
||||
supplied fields and leaves omitted fields untouched (exclude_unset
|
||||
semantics); a supplied ``windows`` list replaces the whole set; an
|
||||
omitted ``assignee_child_ids`` leaves the assignees untouched (the
|
||||
supplied-list cases live in test_api_admin_questdef_assignees.py).
|
||||
semantics); a supplied ``windows`` list replaces the whole set;
|
||||
assignment is not settable through the edit.
|
||||
- ``PATCH /api/v1/admin/quest-definitions/{id}/active`` with
|
||||
``is_active: false`` sets the flag FALSE and the definition STILL
|
||||
EXISTS with its rule, assignees and windows (never hard-deleted);
|
||||
@@ -216,7 +215,6 @@ async def test_create_persists_definition_with_assignees_and_windows(
|
||||
"description",
|
||||
"icon",
|
||||
"is_active",
|
||||
"skip_on_away",
|
||||
"rule",
|
||||
"assignees",
|
||||
"windows",
|
||||
@@ -225,7 +223,6 @@ async def test_create_persists_definition_with_assignees_and_windows(
|
||||
assert payload["description"] == "Put everything back where it belongs."
|
||||
assert payload["icon"] == "toy-box"
|
||||
assert payload["is_active"] is True
|
||||
assert payload["skip_on_away"] is True
|
||||
assert payload["rule"] == _weekly_rule_dict()
|
||||
assert {a["id"]: a["display_name"] for a in payload["assignees"]} == {
|
||||
ada: "Ada",
|
||||
@@ -398,7 +395,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
|
||||
)
|
||||
assert stored.definition.icon == "toy-box"
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
# assignee_child_ids was omitted: the assignees are untouched.
|
||||
# Assignment is not settable through the edit route: untouched.
|
||||
assert sorted(child.id for child in stored.assignees) == [ada, bo]
|
||||
|
||||
# A second edit touching only the rule leaves the metadata and the
|
||||
@@ -524,130 +521,9 @@ async def test_edit_rejections_are_422_and_change_nothing(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", [0, 1, "true", "false"])
|
||||
async def test_create_rejects_coercible_skip_on_away_and_persists_nothing(
|
||||
admin_questdefs: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A numeric or string skip_on_away is 422, not coerced to a bool."""
|
||||
client = admin_questdefs.client
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
existing = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Feed the cat",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada],
|
||||
"windows": ["morning"],
|
||||
"skip_on_away": False,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert existing.status_code == 201
|
||||
existing_id = existing.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada],
|
||||
"windows": ["morning"],
|
||||
"skip_on_away": coercible,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert set(await _definitions_by_id(admin_questdefs.database)) == {
|
||||
existing_id
|
||||
}
|
||||
assert (await dao.get(existing_id)).skip_on_away is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", [0, 1, "true", "false"])
|
||||
async def test_edit_rejects_coercible_skip_on_away_and_changes_nothing(
|
||||
admin_questdefs: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A numeric or string skip_on_away edit is 422; the flag is kept."""
|
||||
client = admin_questdefs.client
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
definition_id = (await _create_definition(client, [ada])).json()["id"]
|
||||
# Start from each stored value so a coerced write would be visible.
|
||||
for stored in (True, False):
|
||||
set_flag = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": stored},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert set_flag.status_code == 200
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": coercible, "title": "Renamed"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
stored_definition = await dao.get(definition_id)
|
||||
assert stored_definition.skip_on_away is stored
|
||||
assert stored_definition.title == "Tidy the den"
|
||||
|
||||
|
||||
# --- set_active -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_edit_toggles_skip_on_away_and_omission_keeps_it(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A supplied skip_on_away persists; an edit omitting it keeps it."""
|
||||
client = admin_questdefs.client
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
assert created.status_code == 201
|
||||
definition_id = created.json()["id"]
|
||||
assert created.json()["skip_on_away"] is True
|
||||
|
||||
toggled_off = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert toggled_off.status_code == 200
|
||||
assert toggled_off.json()["skip_on_away"] is False
|
||||
assert (await dao.get(definition_id)).skip_on_away is False
|
||||
|
||||
# An edit that omits the flag leaves the stored False untouched.
|
||||
renamed = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"title": "Tidy the den properly"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert renamed.status_code == 200
|
||||
assert renamed.json()["skip_on_away"] is False
|
||||
stored = await dao.get(definition_id)
|
||||
assert stored.title == "Tidy the den properly"
|
||||
assert stored.skip_on_away is False
|
||||
|
||||
toggled_on = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert toggled_on.status_code == 200
|
||||
assert toggled_on.json()["skip_on_away"] is True
|
||||
assert (await dao.get(definition_id)).skip_on_away is True
|
||||
|
||||
# A non-boolean value is a malformed body: 422, nothing changes.
|
||||
rejected = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": "sometimes"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert rejected.status_code == 422
|
||||
assert (await dao.get(definition_id)).skip_on_away is True
|
||||
|
||||
|
||||
async def test_set_active_toggles_the_flag_and_the_definition_survives(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
"""Admin-plane editable assignees on the definition edit route (task 1c8096f6).
|
||||
|
||||
``PATCH /api/v1/admin/quest-definitions/{definition_id}`` accepts an
|
||||
optional ``assignee_child_ids`` list that REPLACES the definition's
|
||||
assignee set inside the core edit's ONE transaction
|
||||
(:func:`nestquest_core.quest_definitions.edit_quest_definition`):
|
||||
|
||||
- newly listed children are assigned and unlisted ones unassigned; the
|
||||
response and the database both carry the resulting set;
|
||||
- an empty list, a duplicate, an unknown child or an inactive child is
|
||||
422 and NOTHING is written — the stored assignees (and any other
|
||||
field supplied in the same body) are unchanged;
|
||||
- a JSON boolean, float or numeric string id is 422 on create and edit
|
||||
(never coerced to an int) and nothing is written;
|
||||
- an omitted ``assignee_child_ids`` leaves the assignees unchanged;
|
||||
- an assignment change regenerates the definition's future instances
|
||||
through the existing core path;
|
||||
- the route stays behind the router's ONE ``require_admin`` dependency
|
||||
(absent credential 401, panel service token 403, non-admin JWT 403,
|
||||
admin JWT 200).
|
||||
|
||||
Every case is proven against the DATABASE, read through the SAME core
|
||||
copy the app uses (``nestquest_core.*``; see api/nestquest_core.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
_dao_rules = importlib.import_module("nestquest_core.dao_rules")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
_quest_definitions = importlib.import_module(
|
||||
"nestquest_core.quest_definitions"
|
||||
)
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _edit_path(definition_id: int) -> str:
|
||||
"""The edit route for one definition."""
|
||||
return f"/api/v1/admin/quest-definitions/{definition_id}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_assignees(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=runner.app.state.db.database,
|
||||
)
|
||||
|
||||
|
||||
async def _create_children(
|
||||
client: object, names: tuple[str, ...]
|
||||
) -> list[int]:
|
||||
"""Create ACTIVE children through the admin children route."""
|
||||
ids = []
|
||||
for name in names:
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
ids.append(created.json()["id"])
|
||||
return ids
|
||||
|
||||
|
||||
async def _create_definition(client: object, assignee_ids: list[int]) -> int:
|
||||
"""POST a daily morning definition for the given assignees."""
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": assignee_ids,
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
async def _stored_assignee_ids(
|
||||
database: object, definition_id: int
|
||||
) -> set[int]:
|
||||
"""The definition's assignee ids as stored."""
|
||||
dao = _dao_rules.QuestDefinitionsDao(database)
|
||||
return {child.id for child in await dao.list_assignees(definition_id)}
|
||||
|
||||
|
||||
def _response_assignee_ids(response: object) -> set[int]:
|
||||
"""The assignee ids a definition payload reports."""
|
||||
return {assignee["id"] for assignee in response.json()["assignees"]}
|
||||
|
||||
|
||||
# --- add / remove / replace -------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial", "requested"),
|
||||
[
|
||||
(("Ada",), ("Ada", "Bo")), # add a child
|
||||
(("Ada", "Bo"), ("Ada",)), # remove a child
|
||||
(("Ada", "Bo"), ("Cy",)), # replace the whole set
|
||||
],
|
||||
)
|
||||
async def test_edit_replaces_the_assignee_set(
|
||||
admin_assignees: SimpleNamespace,
|
||||
initial: tuple[str, ...],
|
||||
requested: tuple[str, ...],
|
||||
) -> None:
|
||||
"""A supplied list becomes exactly the stored and returned set."""
|
||||
client = admin_assignees.client
|
||||
names = ("Ada", "Bo", "Cy")
|
||||
ids = dict(zip(names, await _create_children(client, names)))
|
||||
definition_id = await _create_definition(
|
||||
client, [ids[name] for name in initial]
|
||||
)
|
||||
wanted = {ids[name] for name in requested}
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"assignee_child_ids": [ids[name] for name in requested]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _response_assignee_ids(response) == wanted
|
||||
assert {a["display_name"] for a in response.json()["assignees"]} == set(
|
||||
requested
|
||||
)
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == wanted
|
||||
# The rest of the definition is untouched by an assignee-only edit.
|
||||
assert response.json()["title"] == "Brush teeth"
|
||||
assert [w["window"] for w in response.json()["windows"]] == ["morning"]
|
||||
|
||||
|
||||
async def test_edit_with_assignees_and_other_fields_applies_both(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Assignees and metadata supplied together land in one edit."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada])
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Floss", "assignee_child_ids": [bo]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["title"] == "Floss"
|
||||
assert _response_assignee_ids(response) == {bo}
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
|
||||
assert (await dao.get(definition_id)).title == "Floss"
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {bo}
|
||||
|
||||
|
||||
# --- rejections leave the stored state unchanged ----------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "expected_detail"),
|
||||
[
|
||||
("empty", "assignee_child_ids must not be empty"),
|
||||
("unknown", "assignee_child_ids: child 999 does not exist"),
|
||||
("inactive", "assignee_child_ids: child {cy} is inactive"),
|
||||
("duplicate", "assignee_child_ids must not contain duplicate"),
|
||||
("null", "assignee_child_ids must be a list"),
|
||||
("mixed_unknown", "assignee_child_ids: child 999 does not exist"),
|
||||
("wrong_type", None), # the body model's own 422
|
||||
],
|
||||
)
|
||||
async def test_rejected_assignee_list_is_422_and_changes_nothing(
|
||||
admin_assignees: SimpleNamespace,
|
||||
case: str,
|
||||
expected_detail: str | None,
|
||||
) -> None:
|
||||
"""Empty/unknown/inactive/malformed lists are 422 with no mutation.
|
||||
|
||||
The same body also renames the definition, proving the whole edit
|
||||
is all-or-nothing: neither the assignees nor the title change.
|
||||
"""
|
||||
client = admin_assignees.client
|
||||
ada, bo, cy, di = await _create_children(
|
||||
client, ("Ada", "Bo", "Cy", "Di")
|
||||
)
|
||||
definition_id = await _create_definition(client, [ada, bo])
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{cy}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
requested: object = {
|
||||
"empty": [],
|
||||
"unknown": [999],
|
||||
"inactive": [ada, cy],
|
||||
"duplicate": [ada, ada],
|
||||
"null": None,
|
||||
# A valid new child alongside an unknown one: di must NOT be
|
||||
# assigned (all-or-nothing), and ada/bo must NOT be removed.
|
||||
"mixed_unknown": [di, 999],
|
||||
"wrong_type": ["Ada"],
|
||||
}[case]
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Renamed", "assignee_child_ids": requested},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 422, case
|
||||
if expected_detail is not None:
|
||||
assert response.json()["detail"].startswith(
|
||||
expected_detail.format(cy=cy)
|
||||
), response.json()
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {ada, bo}
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
|
||||
assert (await dao.get(definition_id)).title == "Brush teeth"
|
||||
|
||||
|
||||
#: JSON ids the lax ``int`` parser would coerce (``true`` -> 1).
|
||||
_COERCIBLE_IDS = [True, 1.0, "1"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
|
||||
async def test_edit_rejects_coercible_assignee_ids_and_changes_nothing(
|
||||
admin_assignees: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A bool, float or numeric-string id edit is 422; nothing changes."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
# Assign bo only, so a coerced id 1 (ada) would be a visible change.
|
||||
definition_id = await _create_definition(client, [bo])
|
||||
assert ada == 1
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Renamed", "assignee_child_ids": [coercible]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {bo}
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
|
||||
assert (await dao.get(definition_id)).title == "Brush teeth"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
|
||||
async def test_create_rejects_coercible_assignee_ids_and_persists_nothing(
|
||||
admin_assignees: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A bool, float or numeric-string id create is 422; nothing is written."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
assert ada == 1
|
||||
existing_id = await _create_definition(client, [bo])
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [coercible],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
bundles = await _quest_definitions.list_active_definitions(
|
||||
admin_assignees.database
|
||||
)
|
||||
assert [bundle.definition.id for bundle in bundles] == [existing_id]
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, existing_id
|
||||
) == {bo}
|
||||
|
||||
|
||||
async def test_rejected_assignees_on_unknown_definition_is_404(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown definition id is still 404 when assignees are supplied."""
|
||||
client = admin_assignees.client
|
||||
(ada,) = await _create_children(client, ("Ada",))
|
||||
response = await client.patch(
|
||||
_edit_path(999),
|
||||
json={"assignee_child_ids": [ada]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Quest definition not found"
|
||||
|
||||
|
||||
# --- omitted keeps ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_omitted_assignees_are_unchanged(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An edit without assignee_child_ids never touches the assignees."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada, bo])
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Floss"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _response_assignee_ids(response) == {ada, bo}
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {ada, bo}
|
||||
|
||||
|
||||
# --- regeneration -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_assignment_change_regenerates_future_instances(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Swapping the assignee moves the future open instances with it.
|
||||
|
||||
The regeneration lives in the core edit path the route calls: the
|
||||
definition's open instances at or after today are deleted and the
|
||||
horizon re-materialized for the NEW assignee set.
|
||||
"""
|
||||
client = admin_assignees.client
|
||||
database = admin_assignees.database
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada])
|
||||
|
||||
today = datetime.date.today()
|
||||
start = today.isoformat()
|
||||
end = (today + datetime.timedelta(days=7)).isoformat()
|
||||
await _materialize.materialize(database, start, end, today=today)
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
assert await instances.list_by_date_range(ada, start, end)
|
||||
assert await instances.list_by_date_range(bo, start, end) == []
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"assignee_child_ids": [bo]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert await instances.list_by_date_range(ada, start, end) == []
|
||||
regenerated = await instances.list_by_date_range(bo, start, end)
|
||||
assert regenerated
|
||||
assert {i.definition_id for i in regenerated} == {definition_id}
|
||||
|
||||
|
||||
# --- authentication ---------------------------------------------------------
|
||||
|
||||
|
||||
async def test_assignee_edit_auth_outcomes(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""401 absent, 403 panel token, 403 non-admin, 200 admin."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada])
|
||||
body = {"assignee_child_ids": [bo]}
|
||||
|
||||
absent = await client.patch(_edit_path(definition_id), json=body)
|
||||
assert absent.status_code == 401
|
||||
|
||||
panel = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
||||
)
|
||||
assert panel.status_code == 403
|
||||
|
||||
non_admin_token = make_token(groups=("some-other-group",))
|
||||
non_admin = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin_token}"},
|
||||
)
|
||||
assert non_admin.status_code == 403
|
||||
|
||||
# None of the refused calls reached the handler.
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {ada}
|
||||
|
||||
admin = await client.patch(
|
||||
_edit_path(definition_id), json=body, headers=_admin_headers()
|
||||
)
|
||||
assert admin.status_code == 200
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {bo}
|
||||
@@ -1,299 +0,0 @@
|
||||
"""Admin-plane quest-definition LIST route tests (task d4667573).
|
||||
|
||||
``GET /api/v1/admin/quest-definitions`` against the ASGI app with httpx,
|
||||
reusing the shared local-RSA-key/stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely authenticates
|
||||
with NO network access.
|
||||
|
||||
- An admin JWT lists EVERY definition — active AND inactive — in rising
|
||||
id order, each in the same documented shape as the create/edit
|
||||
responses (id, title, description, icon, is_active, rule, assignees,
|
||||
windows).
|
||||
- An empty household lists nothing: 200 with an empty list.
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an absent credential is 401, the panel service token is
|
||||
refused outright (403, the credential-type detail), and a JWT without
|
||||
the nestquest-admins group is 403.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
#: The admin list route under test.
|
||||
LIST_PATH = "/api/v1/admin/quest-definitions"
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
#: The 403 detail for the panel service token on an admin route.
|
||||
PANEL_TOKEN_REJECTION_DETAIL = (
|
||||
"This credential type is not accepted for admin access"
|
||||
)
|
||||
|
||||
#: The 403 detail for a valid JWT without the admin group.
|
||||
FORBIDDEN_DETAIL = (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client over a fresh (empty) household."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield client
|
||||
|
||||
|
||||
async def _seed_active_and_inactive(client: object) -> tuple[int, int, int]:
|
||||
"""Seed one child, an ACTIVE definition and an INACTIVE one.
|
||||
|
||||
Returns ``(child_id, active_id, inactive_id)``. Seeded through the
|
||||
admin routes so the rows are exactly what the admin plane writes.
|
||||
"""
|
||||
child = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert child.status_code == 201
|
||||
child_id = child.json()["id"]
|
||||
|
||||
active = await client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": {
|
||||
"rule_type": "weekly",
|
||||
"interval": 1,
|
||||
"weekday_set": [0, 2],
|
||||
"start_date": "2026-03-02",
|
||||
},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert active.status_code == 201
|
||||
|
||||
inactive = await client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": "Water the plants",
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"start_date": "2026-03-01",
|
||||
},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["afternoon"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert inactive.status_code == 201
|
||||
inactive_id = inactive.json()["id"]
|
||||
deactivated = await client.patch(
|
||||
f"{LIST_PATH}/{inactive_id}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
return child_id, active.json()["id"], inactive_id
|
||||
|
||||
|
||||
async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""Both definitions come back, by rising id, in the documented shape."""
|
||||
child_id, active_id, inactive_id = await _seed_active_and_inactive(
|
||||
admin_client
|
||||
)
|
||||
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"definitions": [
|
||||
{
|
||||
"id": active_id,
|
||||
"title": "Tidy the den",
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
"is_active": True,
|
||||
"skip_on_away": True,
|
||||
"rule": {
|
||||
"rule_type": "weekly",
|
||||
"interval": 1,
|
||||
"weekday_set": [0, 2],
|
||||
"day_of_month": None,
|
||||
"nth_weekday": None,
|
||||
"nth_weekday_weekday": None,
|
||||
"month": None,
|
||||
"start_date": "2026-03-02",
|
||||
"end_date": None,
|
||||
},
|
||||
"assignees": [{"id": child_id, "display_name": "Ada"}],
|
||||
"windows": [
|
||||
{"window": "morning", "due_time": None},
|
||||
{"window": "evening", "due_time": "19:30"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": inactive_id,
|
||||
"title": "Water the plants",
|
||||
"description": None,
|
||||
"icon": None,
|
||||
"is_active": False,
|
||||
"skip_on_away": True,
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"weekday_set": None,
|
||||
"day_of_month": None,
|
||||
"nth_weekday": None,
|
||||
"nth_weekday_weekday": None,
|
||||
"month": None,
|
||||
"start_date": "2026-03-01",
|
||||
"end_date": None,
|
||||
},
|
||||
"assignees": [{"id": child_id, "display_name": "Ada"}],
|
||||
"windows": [{"window": "afternoon", "due_time": None}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def test_list_items_match_the_create_response_shape(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""Each listed item is exactly what the create route returned."""
|
||||
child = await admin_client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ben"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
created = await admin_client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": "Feed the cat",
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"start_date": "2026-03-01",
|
||||
},
|
||||
"assignee_child_ids": [child.json()["id"]],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"definitions": [created.json()]}
|
||||
|
||||
|
||||
async def test_skip_on_away_round_trips_true_and_false_through_list(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""skip_on_away posted true, false, or omitted lists back as stored."""
|
||||
child = await admin_client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Cleo"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
child_id = child.json()["id"]
|
||||
|
||||
async def create(title: str, extra: dict[str, object]) -> dict:
|
||||
response = await admin_client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": title,
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"start_date": "2026-03-01",
|
||||
},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["morning"],
|
||||
**extra,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()
|
||||
|
||||
skipping = await create("Skip when away", {"skip_on_away": True})
|
||||
keeping = await create("Keep when away", {"skip_on_away": False})
|
||||
defaulted = await create("Default", {})
|
||||
assert skipping["skip_on_away"] is True
|
||||
assert keeping["skip_on_away"] is False
|
||||
assert defaulted["skip_on_away"] is True
|
||||
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
listed = {item["id"]: item for item in response.json()["definitions"]}
|
||||
assert listed[skipping["id"]]["skip_on_away"] is True
|
||||
assert listed[keeping["id"]]["skip_on_away"] is False
|
||||
assert listed[defaulted["id"]]["skip_on_away"] is True
|
||||
assert response.json() == {"definitions": [skipping, keeping, defaulted]}
|
||||
|
||||
|
||||
async def test_empty_household_lists_no_definitions(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""With no definitions stored the list is 200 and empty."""
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"definitions": []}
|
||||
|
||||
|
||||
async def test_list_without_credentials_is_401(admin_client: object) -> None:
|
||||
"""An absent credential is the uniform 401."""
|
||||
response = await admin_client.get(LIST_PATH)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_list_with_panel_service_token_is_403(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""The panel service token is refused outright, never a JWT."""
|
||||
response = await admin_client.get(
|
||||
LIST_PATH,
|
||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_list_with_non_admin_jwt_is_403(admin_client: object) -> None:
|
||||
"""A valid JWT without the nestquest-admins group is 403."""
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await admin_client.get(
|
||||
LIST_PATH, headers={"Authorization": f"Bearer {non_admin}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
@@ -1,328 +0,0 @@
|
||||
"""Admin-plane snapshot route tests (task ef3f4877).
|
||||
|
||||
These tests exercise ``GET /api/v1/admin/snapshot`` against the ASGI
|
||||
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
|
||||
authenticates with NO network access. The household is seeded with
|
||||
:func:`tests.test_api_panel._seed_household` through the SAME core
|
||||
copy the app uses (``nestquest_core.*``), and both routes' single
|
||||
clock read (``_local_now``) is pinned to noon so nothing straddles
|
||||
midnight.
|
||||
|
||||
Proven per the done-condition:
|
||||
|
||||
- An admin JWT returns 200 with the documented shape: ``today_iso``,
|
||||
``cycle_day`` and, per child, the presence fields, the rollup counts
|
||||
and ``instances`` in the core ``instance_payload`` shape.
|
||||
- A MISSED instance is KEPT in the admin payload (D-009) and still
|
||||
OMITTED from the panel payload. The core builder lists only
|
||||
instances due on ``now.date()``, and ``derive_state`` calls an
|
||||
instance missed only when its due date is BEFORE that day — so a
|
||||
real snapshot never carries a missed view. The test therefore
|
||||
wraps the real builder to add the seeded past-due instance as a
|
||||
view whose state the core's own ``derive_state`` computes
|
||||
(``missed``), and reads it through both routes.
|
||||
- Auth runs through the router's ONE ``require_admin`` dependency: no
|
||||
credential is 401, the panel service token is refused outright
|
||||
(403, the credential-type detail), and a non-admin JWT is 403.
|
||||
- The handler performs exactly ONE clock read per request.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from api import routes_admin, routes_panel
|
||||
from api.nestquest_core import core_snapshot
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
from tests.test_api_panel import _seed_household
|
||||
|
||||
# The completion layer through the SAME core copy the app uses — the
|
||||
# missed test derives its view's state with the core's own rule.
|
||||
_completion = importlib.import_module("nestquest_core.completion")
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
#: The 403 detail for the panel service token on an admin route.
|
||||
PANEL_TOKEN_REJECTION_DETAIL = (
|
||||
"This credential type is not accepted for admin access"
|
||||
)
|
||||
|
||||
#: The 403 detail for a valid JWT without the admin group.
|
||||
FORBIDDEN_DETAIL = (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _panel_headers() -> dict[str, str]:
|
||||
"""The Authorization header carrying the harness panel token."""
|
||||
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def snapshot_household(
|
||||
temp_db_path: str, monkeypatch
|
||||
) -> SimpleNamespace:
|
||||
"""A seeded household served by the admin-plane app, clock pinned.
|
||||
|
||||
``today`` comes from one host clock read; the pinned instant is
|
||||
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). Both
|
||||
the admin and the panel route's ``_local_now`` return it, and the
|
||||
admin one counts its calls.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
pinned_now = datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
|
||||
)
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
|
||||
clock_reads: list[datetime.datetime] = []
|
||||
|
||||
def _admin_now() -> datetime.datetime:
|
||||
clock_reads.append(pinned_now)
|
||||
return pinned_now
|
||||
|
||||
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
|
||||
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
seed=seed,
|
||||
today=today,
|
||||
pinned_now=pinned_now,
|
||||
pinned_utc_iso=pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
clock_reads=clock_reads,
|
||||
)
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency ----------------------
|
||||
|
||||
|
||||
async def test_snapshot_without_credential_is_401(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""No Authorization header is the uniform 401."""
|
||||
response = await snapshot_household.client.get("/api/v1/admin/snapshot")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_snapshot_refuses_panel_service_token_outright(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The panel service token is refused as a credential type (403).
|
||||
|
||||
403 with the credential-type detail — not the uniform 401 and not
|
||||
the non-admin group detail — proves the token was refused outright,
|
||||
never tried as a JWT.
|
||||
"""
|
||||
response = await snapshot_household.client.get(
|
||||
"/api/v1/admin/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_snapshot_refuses_non_admin_jwt(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A valid JWT without the nestquest-admins group is 403."""
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await snapshot_household.client.get(
|
||||
"/api/v1/admin/snapshot",
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
# --- the payload -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_admin_jwt_returns_documented_snapshot(
|
||||
snapshot_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An admin JWT returns 200 with the documented snapshot shape.
|
||||
|
||||
Every field is asserted as an explicit literal, with ONE clock
|
||||
read for the request. With no missed view in the real snapshot,
|
||||
the admin body equals the panel body for the same household.
|
||||
"""
|
||||
client = snapshot_household.client
|
||||
seed = snapshot_household.seed
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/snapshot", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(snapshot_household.clock_reads) == 1
|
||||
body = response.json()
|
||||
|
||||
assert body == {
|
||||
"today_iso": snapshot_household.today.isoformat(),
|
||||
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
|
||||
"cycle_day": 2,
|
||||
"children": [
|
||||
{
|
||||
"child_id": seed.ada.id,
|
||||
"child_name": "Ada",
|
||||
"present": False,
|
||||
"next_present": seed.returns_date.isoformat(),
|
||||
"due_today": 1,
|
||||
"completed_today": 0,
|
||||
"remaining_today": 1,
|
||||
"completion_pct": 0,
|
||||
"instances": [
|
||||
{
|
||||
"id": seed.pack_bag_instance.id,
|
||||
"definition_id": seed.pack_bag_instance.definition_id,
|
||||
"child_id": seed.ada.id,
|
||||
"title": "Pack bag",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "10:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"child_id": seed.bo.id,
|
||||
"child_name": "Bo",
|
||||
"present": True,
|
||||
"next_present": None,
|
||||
"due_today": 1,
|
||||
"completed_today": 1,
|
||||
"remaining_today": 0,
|
||||
"completion_pct": 100,
|
||||
"instances": [
|
||||
{
|
||||
"id": seed.brush_teeth_instance.id,
|
||||
"definition_id": (
|
||||
seed.brush_teeth_instance.definition_id
|
||||
),
|
||||
"child_id": seed.bo.id,
|
||||
"title": "Brush teeth",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "17:00",
|
||||
"state": "completed",
|
||||
"overdue": False,
|
||||
"completed_at": snapshot_household.pinned_utc_iso,
|
||||
"on_time": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
panel = await client.get(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert panel.status_code == 200
|
||||
assert panel.json() == body
|
||||
|
||||
|
||||
async def test_admin_keeps_missed_instance_panel_omits_it(
|
||||
snapshot_household: SimpleNamespace, monkeypatch
|
||||
) -> None:
|
||||
"""A missed view is KEPT by the admin route, OMITTED by the panel's.
|
||||
|
||||
The real builder is wrapped to add the seeded past-due Stale chore
|
||||
instance to Ada's views, its state derived by the core's own
|
||||
``derive_state`` (no events, due yesterday => ``missed``). Both
|
||||
routes read the SAME wrapped snapshot, so the only difference is
|
||||
each route's ``include_missed`` flag.
|
||||
"""
|
||||
client = snapshot_household.client
|
||||
seed = snapshot_household.seed
|
||||
stale = seed.stale_instance
|
||||
missed_state = _completion.derive_state(
|
||||
stale, None, snapshot_household.today
|
||||
)
|
||||
assert missed_state == "missed"
|
||||
missed_view = core_snapshot.QuestInstanceView(
|
||||
instance_id=stale.id,
|
||||
definition_id=stale.definition_id,
|
||||
child_id=stale.child_id,
|
||||
title="Stale chore",
|
||||
icon=None,
|
||||
window=stale.window,
|
||||
due_date=stale.due_date,
|
||||
due_time=stale.due_time,
|
||||
state=missed_state,
|
||||
overdue=False,
|
||||
completed_at=None,
|
||||
was_on_time=None,
|
||||
)
|
||||
real_build_snapshot = core_snapshot.build_snapshot
|
||||
|
||||
async def _with_missed_view(database, settings, now):
|
||||
snapshot = await real_build_snapshot(database, settings, now)
|
||||
return dataclasses.replace(
|
||||
snapshot,
|
||||
children=tuple(
|
||||
dataclasses.replace(
|
||||
child, instances=(*child.instances, missed_view)
|
||||
)
|
||||
if child.child_id == seed.ada.id
|
||||
else child
|
||||
for child in snapshot.children
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(core_snapshot, "build_snapshot", _with_missed_view)
|
||||
|
||||
admin = await client.get(
|
||||
"/api/v1/admin/snapshot", headers=_admin_headers()
|
||||
)
|
||||
assert admin.status_code == 200
|
||||
admin_ada = admin.json()["children"][0]
|
||||
assert admin_ada["child_id"] == seed.ada.id
|
||||
assert [instance["id"] for instance in admin_ada["instances"]] == [
|
||||
seed.pack_bag_instance.id,
|
||||
stale.id,
|
||||
]
|
||||
assert admin_ada["instances"][1] == {
|
||||
"id": stale.id,
|
||||
"definition_id": stale.definition_id,
|
||||
"child_id": seed.ada.id,
|
||||
"title": "Stale chore",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "09:00",
|
||||
"state": "missed",
|
||||
"overdue": False,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
|
||||
panel = await client.get(
|
||||
"/api/v1/panel/snapshot", headers=_panel_headers()
|
||||
)
|
||||
assert panel.status_code == 200
|
||||
panel_ada = panel.json()["children"][0]
|
||||
assert [instance["id"] for instance in panel_ada["instances"]] == [
|
||||
seed.pack_bag_instance.id
|
||||
], "the panel payload must still omit missed instances"
|
||||
@@ -1,450 +0,0 @@
|
||||
"""Tests for the panel-plane API client (Feature 18).
|
||||
|
||||
The client is exercised against a STUB transport only — no network, no
|
||||
aiohttp, no homeassistant: the injected session is a fake that speaks
|
||||
the same ``request(...)`` surface aiohttp's ``ClientSession`` exposes
|
||||
and records every call, so the tests assert exactly what the client
|
||||
puts on the wire (method, URL, Bearer header, JSON body) and how every
|
||||
failure mode (non-2xx, connection failure, timeout, malformed JSON)
|
||||
surfaces as the typed :class:`NestQuestApiError`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import make_config_entry
|
||||
|
||||
from custom_components.nestquest import api_client
|
||||
from custom_components.nestquest.api_client import (
|
||||
NestQuestApiClient,
|
||||
NestQuestApiError,
|
||||
is_valid_base_url,
|
||||
resolve_api_config,
|
||||
)
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_API_BASE_URL,
|
||||
CONF_PANEL_TOKEN,
|
||||
DEFAULT_API_BASE_URL,
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
)
|
||||
|
||||
BASE_URL = "http://api.test:8000"
|
||||
TOKEN = "test-panel-token"
|
||||
|
||||
#: A realistic panel snapshot payload (the documented shape of the
|
||||
#: Feature 16 snapshot route).
|
||||
SNAPSHOT_PAYLOAD = {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 2,
|
||||
"children": [
|
||||
{
|
||||
"child_id": 1,
|
||||
"child_name": "Ada",
|
||||
"present": False,
|
||||
"next_present": "2026-09-24",
|
||||
"due_today": 1,
|
||||
"completed_today": 0,
|
||||
"remaining_today": 1,
|
||||
"completion_pct": 0,
|
||||
"instances": [
|
||||
{
|
||||
"id": 7,
|
||||
"definition_id": 3,
|
||||
"child_id": 1,
|
||||
"title": "Pack bag",
|
||||
"icon": None,
|
||||
"window": "morning",
|
||||
"due_time": "10:00",
|
||||
"state": "open",
|
||||
"overdue": True,
|
||||
"completed_at": None,
|
||||
"on_time": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class StubResponse:
|
||||
"""A fake aiohttp response: ``status`` plus ``await text()``."""
|
||||
|
||||
def __init__(self, status: int, body: str) -> None:
|
||||
self.status = status
|
||||
self._body = body
|
||||
|
||||
async def text(self) -> str:
|
||||
return self._body
|
||||
|
||||
|
||||
class _OutcomeContext:
|
||||
"""Async context manager yielding one scripted outcome.
|
||||
|
||||
Mirrors aiohttp's ``session.request(...)`` return value: entering
|
||||
the context performs the request, so a scripted transport error
|
||||
raises THERE — before any response object exists, exactly where
|
||||
aiohttp's connector raises on a refused connection.
|
||||
"""
|
||||
|
||||
def __init__(self, response, error) -> None:
|
||||
self._response = response
|
||||
self._error = error
|
||||
|
||||
async def __aenter__(self):
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._response
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class StubTransport:
|
||||
"""A no-network stand-in for aiohttp's ``ClientSession``.
|
||||
|
||||
Records every ``request(...)`` call as a dict (method, url, headers,
|
||||
json body) and answers from a scripted queue of outcomes — a
|
||||
:class:`StubResponse` or an exception per call, consumed in order —
|
||||
so each test drives exactly one exchange.
|
||||
"""
|
||||
|
||||
def __init__(self, *outcomes) -> None:
|
||||
self.calls: list[dict] = []
|
||||
self._outcomes = list(outcomes)
|
||||
|
||||
def request(self, method: str, url: str, **kwargs):
|
||||
self.calls.append(
|
||||
{
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": kwargs.get("headers"),
|
||||
"json": kwargs.get("json"),
|
||||
}
|
||||
)
|
||||
if not self._outcomes:
|
||||
raise AssertionError("StubTransport ran out of scripted outcomes")
|
||||
outcome = self._outcomes.pop(0)
|
||||
if isinstance(outcome, BaseException):
|
||||
return _OutcomeContext(None, outcome)
|
||||
return _OutcomeContext(outcome, None)
|
||||
|
||||
|
||||
class HangingTransport:
|
||||
"""A transport whose request never answers (for the timeout test)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def request(self, method: str, url: str, **kwargs):
|
||||
self.calls.append({"method": method, "url": url})
|
||||
return _HangingContext()
|
||||
|
||||
|
||||
class _HangingContext:
|
||||
async def __aenter__(self):
|
||||
await asyncio.sleep(5)
|
||||
raise AssertionError("hanging transport was never cancelled")
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def make_client(transport, *, timeout_seconds: float = 5.0) -> NestQuestApiClient:
|
||||
"""Build a client over the stub transport with the test credentials."""
|
||||
return NestQuestApiClient(
|
||||
BASE_URL, TOKEN, transport, timeout_seconds=timeout_seconds
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Success paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_get_snapshot_parses_payload_and_sends_bearer() -> None:
|
||||
transport = StubTransport(
|
||||
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD))
|
||||
)
|
||||
client = make_client(transport)
|
||||
|
||||
snapshot = await client.get_snapshot()
|
||||
|
||||
assert snapshot == SNAPSHOT_PAYLOAD
|
||||
call = transport.calls[0]
|
||||
assert call["method"] == "GET"
|
||||
assert call["url"] == f"{BASE_URL}/api/v1/panel/snapshot"
|
||||
assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"}
|
||||
assert call["json"] is None
|
||||
|
||||
|
||||
async def test_complete_instance_posts_body_and_headers() -> None:
|
||||
transport = StubTransport(StubResponse(200, '{"status": "done"}'))
|
||||
client = make_client(transport)
|
||||
|
||||
result = await client.complete_instance(42, 3)
|
||||
|
||||
assert result == {"status": "done"}
|
||||
call = transport.calls[0]
|
||||
assert call["method"] == "POST"
|
||||
assert call["url"] == f"{BASE_URL}/api/v1/panel/instances/42/complete"
|
||||
assert call["json"] == {"actor_child_id": 3}
|
||||
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
|
||||
|
||||
|
||||
async def test_base_url_trailing_slash_is_normalized() -> None:
|
||||
"""A base URL with a trailing slash must not double the path slash."""
|
||||
transport = StubTransport(StubResponse(200, "{}"))
|
||||
client = NestQuestApiClient(
|
||||
f"{BASE_URL}/", TOKEN, transport, timeout_seconds=5.0
|
||||
)
|
||||
await client.get_snapshot()
|
||||
assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/snapshot"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP failures: the typed error carries the status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_snapshot_401_raises_typed_error() -> None:
|
||||
"""A wrong/missing token answers 401 and surfaces with that status."""
|
||||
transport = StubTransport(
|
||||
StubResponse(401, '{"detail": "Missing or invalid panel service token"}')
|
||||
)
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.get_snapshot()
|
||||
|
||||
error = excinfo.value
|
||||
assert error.status == 401
|
||||
assert not error.is_transport_error
|
||||
assert error.__cause__ is None
|
||||
# The token is a secret: it never reaches the raised message.
|
||||
assert TOKEN not in str(error)
|
||||
|
||||
|
||||
async def test_complete_unknown_instance_404_raises_typed_error() -> None:
|
||||
transport = StubTransport(
|
||||
StubResponse(404, '{"detail": "Quest instance not found"}')
|
||||
)
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.complete_instance(999999, 1)
|
||||
|
||||
assert excinfo.value.status == 404
|
||||
assert not excinfo.value.is_transport_error
|
||||
|
||||
|
||||
async def test_snapshot_500_raises_typed_error() -> None:
|
||||
"""Any non-2xx response is typed, whatever the status."""
|
||||
transport = StubTransport(StubResponse(500, "{}"))
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.get_snapshot()
|
||||
|
||||
assert excinfo.value.status == 500
|
||||
|
||||
|
||||
async def test_malformed_2xx_body_raises_typed_with_status() -> None:
|
||||
"""A 2xx body that is not JSON is a protocol failure, carrying its status."""
|
||||
transport = StubTransport(StubResponse(200, "<html>not json</html>"))
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.get_snapshot()
|
||||
|
||||
assert excinfo.value.status == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transport failures: the typed error carries no status and chains the cause
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_connection_failure_raises_transport_error() -> None:
|
||||
transport = StubTransport(OSError("connection refused"))
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.get_snapshot()
|
||||
|
||||
error = excinfo.value
|
||||
assert error.status is None
|
||||
assert error.is_transport_error
|
||||
assert isinstance(error.__cause__, OSError)
|
||||
|
||||
|
||||
async def test_connection_failure_on_complete_is_typed() -> None:
|
||||
transport = StubTransport(OSError("connection refused"))
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.complete_instance(7, 1)
|
||||
|
||||
assert excinfo.value.status is None
|
||||
assert excinfo.value.is_transport_error
|
||||
|
||||
|
||||
async def test_request_timeout_raises_transport_error() -> None:
|
||||
"""A hung API call surfaces as a typed transport error, once, with
|
||||
no retry: the one request is cancelled by the client's timeout."""
|
||||
transport = HangingTransport()
|
||||
client = make_client(transport, timeout_seconds=0.05)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
await client.get_snapshot()
|
||||
|
||||
assert excinfo.value.status is None
|
||||
assert excinfo.value.is_transport_error
|
||||
assert len(transport.calls) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction and config resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_constructor_requires_full_base_url() -> None:
|
||||
for bad_url in (None, "", " ", "api.lan:8000", "http://", "ftp://x"):
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestApiClient(bad_url, TOKEN, StubTransport())
|
||||
|
||||
|
||||
def test_constructor_requires_non_empty_token() -> None:
|
||||
for bad_token in (None, "", " "):
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestApiClient(BASE_URL, bad_token, StubTransport())
|
||||
|
||||
|
||||
def test_constructor_requires_positive_timeout() -> None:
|
||||
for bad_timeout in (0, -1, "5", True, None):
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestApiClient(
|
||||
BASE_URL, TOKEN, StubTransport(), timeout_seconds=bad_timeout
|
||||
)
|
||||
|
||||
|
||||
def test_is_valid_base_url_accepts_full_http_urls() -> None:
|
||||
assert is_valid_base_url("http://127.0.0.1:8000")
|
||||
assert is_valid_base_url("https://nestquest.lan")
|
||||
assert is_valid_base_url(" http://127.0.0.1:8000 ")
|
||||
assert not is_valid_base_url("api.lan:8000")
|
||||
assert not is_valid_base_url("http://")
|
||||
assert not is_valid_base_url("")
|
||||
assert not is_valid_base_url(None)
|
||||
assert not is_valid_base_url(42)
|
||||
|
||||
|
||||
def test_resolve_api_config_prefers_options_then_data_then_defaults() -> None:
|
||||
options_only = make_config_entry(
|
||||
options={CONF_API_BASE_URL: "http://from-options:1"}
|
||||
)
|
||||
assert resolve_api_config(options_only) == (
|
||||
"http://from-options:1",
|
||||
DEFAULT_PANEL_TOKEN,
|
||||
)
|
||||
|
||||
data_only = make_config_entry(
|
||||
data={
|
||||
CONF_API_BASE_URL: "http://from-data:2",
|
||||
CONF_PANEL_TOKEN: "from-data",
|
||||
}
|
||||
)
|
||||
assert resolve_api_config(data_only) == ("http://from-data:2", "from-data")
|
||||
|
||||
both = make_config_entry(
|
||||
options={CONF_API_BASE_URL: "http://from-options:3"},
|
||||
data={
|
||||
CONF_API_BASE_URL: "http://from-data:4",
|
||||
CONF_PANEL_TOKEN: "from-data",
|
||||
},
|
||||
)
|
||||
assert resolve_api_config(both) == (
|
||||
"http://from-options:3",
|
||||
"from-data",
|
||||
)
|
||||
|
||||
bare = make_config_entry()
|
||||
assert resolve_api_config(bare) == (DEFAULT_API_BASE_URL, DEFAULT_PANEL_TOKEN)
|
||||
|
||||
|
||||
def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
|
||||
"""The production factory resolves the entry config and hands in HA's
|
||||
shared aiohttp session (stubbed module — no real HA, no network)."""
|
||||
fake_module = types.ModuleType("homeassistant.helpers.aiohttp_client")
|
||||
shared_session = object()
|
||||
fake_module.async_get_clientsession = lambda hass: shared_session
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "homeassistant.helpers.aiohttp_client", fake_module
|
||||
)
|
||||
entry = make_config_entry(
|
||||
options={
|
||||
CONF_API_BASE_URL: "http://panel.lan:8443",
|
||||
CONF_PANEL_TOKEN: "entry-token",
|
||||
}
|
||||
)
|
||||
|
||||
client = api_client.client_from_entry(object(), entry)
|
||||
|
||||
assert client._session is shared_session
|
||||
assert client._base_url == "http://panel.lan:8443"
|
||||
assert client._panel_token == "entry-token"
|
||||
|
||||
|
||||
def test_client_from_entry_unconfigured_token_raises_at_construction(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""An entry with NO configured token (the shipped default) raises
|
||||
ValueError AT CONSTRUCTION — the client's fail-fast guard — rather
|
||||
than silently building a client that can only 401 (the documented
|
||||
contract: the options flow's non-empty validation is the user-facing
|
||||
guard; the coordinator must ensure a token is configured first or
|
||||
handle the construction error as "API not configured")."""
|
||||
fake_module = types.ModuleType("homeassistant.helpers.aiohttp_client")
|
||||
fake_module.async_get_clientsession = lambda hass: object()
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "homeassistant.helpers.aiohttp_client", fake_module
|
||||
)
|
||||
entry = make_config_entry(
|
||||
options={CONF_API_BASE_URL: DEFAULT_API_BASE_URL},
|
||||
)
|
||||
assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == ""
|
||||
|
||||
with pytest.raises(ValueError, match="panel_token must be a non-empty"):
|
||||
api_client.client_from_entry(object(), entry)
|
||||
|
||||
# Same for a whitespace-only stored token: whitespace is not a token.
|
||||
entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "})
|
||||
with pytest.raises(ValueError, match="panel_token must be a non-empty"):
|
||||
api_client.client_from_entry(object(), entry_ws)
|
||||
|
||||
|
||||
async def test_client_logs_nothing() -> None:
|
||||
"""The client emits no log records at all (the token must never
|
||||
reach a log line), success or failure."""
|
||||
import logging
|
||||
|
||||
records: list[logging.LogRecord] = []
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
records.append(record)
|
||||
|
||||
handler = _Capture()
|
||||
logging.getLogger().addHandler(handler)
|
||||
try:
|
||||
transport = StubTransport(StubResponse(401, "{}"))
|
||||
client = make_client(transport)
|
||||
with pytest.raises(NestQuestApiError):
|
||||
await client.get_snapshot()
|
||||
finally:
|
||||
logging.getLogger().removeHandler(handler)
|
||||
assert records == []
|
||||
+123
-134
@@ -1,85 +1,50 @@
|
||||
"""Tests for the per-child binary sensors (Feature 10, task 4).
|
||||
|
||||
The snapshot comes from the API service's panel route (the presence
|
||||
engine resolves server-side), so these tests script the route's
|
||||
payload through :class:`conftest.StubSnapshotClient` instead of
|
||||
seeding a local database.
|
||||
"""
|
||||
"""Tests for the per-child binary sensors (Feature 10, task 4)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
|
||||
import datetime
|
||||
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.children import create_child, list_children
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
|
||||
SEED_HORIZON_DAYS = 3
|
||||
|
||||
|
||||
def _binary(hass, child_id: int, kind: str):
|
||||
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
|
||||
|
||||
|
||||
def _child(
|
||||
child_id: int,
|
||||
name: str,
|
||||
*,
|
||||
present=True,
|
||||
next_present=None,
|
||||
instances=(),
|
||||
):
|
||||
completed = sum(1 for i in instances if i["state"] == "completed")
|
||||
due = len(instances)
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"child_name": name,
|
||||
"present": present,
|
||||
"next_present": next_present,
|
||||
"due_today": due,
|
||||
"completed_today": completed,
|
||||
"remaining_today": due - completed,
|
||||
"completion_pct": 100 if due == 0 else round(completed / due * 100),
|
||||
"instances": list(instances),
|
||||
}
|
||||
|
||||
|
||||
def _instance(instance_id: int, child_id: int, *, state="open") -> dict:
|
||||
return {
|
||||
"id": instance_id,
|
||||
"definition_id": 3,
|
||||
"child_id": child_id,
|
||||
"title": "Brush teeth",
|
||||
"icon": "🦷",
|
||||
"window": "morning",
|
||||
"due_time": "08:00",
|
||||
"state": state,
|
||||
"overdue": False,
|
||||
"completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00",
|
||||
"on_time": None if state == "open" else True,
|
||||
}
|
||||
|
||||
|
||||
def _payload(children) -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"children": list(children),
|
||||
}
|
||||
|
||||
|
||||
ADA = 1
|
||||
BO = 2
|
||||
CORY = 3
|
||||
|
||||
|
||||
async def _setup_entry(hass, make_entry, *payloads):
|
||||
"""Set up the integration with a scripted snapshot client."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(
|
||||
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
set_coordinator_client(entry, StubSnapshotClient(*payloads))
|
||||
async def _setup_entry(hass, make_entry):
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry, entry.runtime_data.coordinator
|
||||
return entry
|
||||
|
||||
|
||||
async def _seed_two_children(database) -> tuple:
|
||||
ada = await create_child(database, "Ada")
|
||||
bo = await create_child(database, "Bo")
|
||||
today = datetime.date.today()
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
||||
),
|
||||
[ada.id, bo.id],
|
||||
["morning"],
|
||||
)
|
||||
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
|
||||
await materialize(database, today.isoformat(), end, today=today)
|
||||
return ada, bo
|
||||
|
||||
|
||||
async def test_all_done_off_with_open_quests_and_zero_quests(
|
||||
@@ -87,76 +52,88 @@ async def test_all_done_off_with_open_quests_and_zero_quests(
|
||||
) -> None:
|
||||
"""all_done is OFF while quests are owed AND on a zero-quest day;
|
||||
a child with no quests never fires the celebration automation."""
|
||||
_entry, coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload(
|
||||
[
|
||||
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
||||
_child(BO, "Bo", instances=[_instance(8, BO)]),
|
||||
_child(CORY, "Cory"),
|
||||
]
|
||||
),
|
||||
)
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory") # zero quests
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert _binary(hass, ADA, "all_done").is_on is False
|
||||
assert _binary(hass, BO, "all_done").is_on is False
|
||||
assert _binary(hass, CORY, "all_done").is_on is False
|
||||
assert _binary(hass, ada.id, "all_done").is_on is False
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
assert _binary(hass, cory.child_id, "all_done").is_on is False
|
||||
|
||||
|
||||
async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None:
|
||||
_entry, coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])]),
|
||||
_payload(
|
||||
[
|
||||
_child(
|
||||
ADA,
|
||||
"Ada",
|
||||
instances=[_instance(7, ADA, state="completed")],
|
||||
)
|
||||
]
|
||||
),
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await coordinator.async_refresh()
|
||||
snapshot_ada = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_id == ada.id
|
||||
][0]
|
||||
instance_id = snapshot_ada.instances[0].instance_id
|
||||
await complete_instance(
|
||||
database,
|
||||
instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=ada.id,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
assert _binary(hass, ADA, "all_done").is_on is True
|
||||
assert _binary(hass, ada.id, "all_done").is_on is True
|
||||
|
||||
|
||||
async def test_present_today_on_without_schedule_off_when_absent(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Presence is resolved by the API's presence engine: present=True
|
||||
renders ON, present=False renders OFF."""
|
||||
_entry, _coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload([_child(ADA, "Ada"), _child(CORY, "Cory", present=False)]),
|
||||
"""No schedule means present every day; an all-absent pattern
|
||||
today reports OFF."""
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory")
|
||||
today = datetime.date.today()
|
||||
await coordinator.async_refresh()
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
cory.child_id, 1, today.isoformat(), ""
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert _binary(hass, ADA, "present_today").is_on is True
|
||||
assert _binary(hass, CORY, "present_today").is_on is False
|
||||
assert _binary(hass, ada.id, "present_today").is_on is True
|
||||
assert _binary(hass, cory.child_id, "present_today").is_on is False
|
||||
|
||||
|
||||
async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
|
||||
_entry, _coordinator = await _setup_entry(
|
||||
hass, make_entry, _payload([_child(ADA, "Ada")])
|
||||
)
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
all_done = _binary(hass, ADA, "all_done")
|
||||
present = _binary(hass, ADA, "present_today")
|
||||
assert all_done.unique_id == f"nestquest_child_{ADA}_all_done"
|
||||
assert present.unique_id == f"nestquest_child_{ADA}_present_today"
|
||||
all_done = _binary(hass, ada.id, "all_done")
|
||||
present = _binary(hass, ada.id, "present_today")
|
||||
assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done"
|
||||
assert present.unique_id == f"nestquest_child_{ada.id}_present_today"
|
||||
assert all_done.name == "NestQuest Ada all done"
|
||||
assert present.name == "NestQuest Ada present today"
|
||||
for entity in (all_done, present):
|
||||
assert entity.device_info.name == "NestQuest Ada"
|
||||
assert all_done.extra_state_attributes == {
|
||||
"child_id": ADA,
|
||||
"child_id": ada.id,
|
||||
"child_name": "Ada",
|
||||
}
|
||||
assert present.extra_state_attributes == {
|
||||
"child_id": ADA,
|
||||
"child_id": ada.id,
|
||||
"child_name": "Ada",
|
||||
"next_present": None,
|
||||
}
|
||||
@@ -168,28 +145,40 @@ async def test_present_today_carries_next_present_when_away(
|
||||
"""A child away today carries the ISO date of their next present
|
||||
day (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
from it); an always-absent pattern carries ``None``."""
|
||||
_entry, _coordinator = await _setup_entry(
|
||||
hass,
|
||||
make_entry,
|
||||
_payload(
|
||||
[
|
||||
_child(
|
||||
ADA,
|
||||
"Ada",
|
||||
present=False,
|
||||
next_present="2026-09-24",
|
||||
),
|
||||
_child(CORY, "Cory", present=False, next_present=None),
|
||||
]
|
||||
),
|
||||
entry = await _setup_entry(hass, make_entry)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
ada, _bo = await _seed_two_children(database)
|
||||
await create_child(database, "Cory")
|
||||
today = datetime.date.today()
|
||||
pattern = ",".join(
|
||||
str(day) for day in range(7) if day != today.weekday()
|
||||
)
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id, 1, today.isoformat(), pattern
|
||||
)
|
||||
await PresenceSchedulesDao(database).upsert_by_child(
|
||||
await _cory_id(database), 1, today.isoformat(), ""
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
assert _binary(hass, ADA, "present_today").is_on is False
|
||||
assert _binary(hass, ADA, "present_today").extra_state_attributes[
|
||||
assert _binary(hass, ada.id, "present_today").is_on is False
|
||||
assert _binary(hass, ada.id, "present_today").extra_state_attributes[
|
||||
"next_present"
|
||||
] == "2026-09-24"
|
||||
] == (today + datetime.timedelta(days=1)).isoformat()
|
||||
|
||||
assert _binary(hass, CORY, "present_today").is_on is False
|
||||
assert _binary(hass, CORY, "present_today").extra_state_attributes[
|
||||
cory = [
|
||||
child for child in coordinator.data.children
|
||||
if child.child_name == "Cory"
|
||||
][0]
|
||||
assert _binary(hass, cory.child_id, "present_today").is_on is False
|
||||
assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[
|
||||
"next_present"
|
||||
] is None
|
||||
|
||||
|
||||
async def _cory_id(database) -> int:
|
||||
for child in await list_children(database):
|
||||
if child.display_name == "Cory":
|
||||
return child.id
|
||||
raise AssertionError("Cory was not created")
|
||||
|
||||
@@ -8,16 +8,16 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.children import (
|
||||
from custom_components.nestquest.children import (
|
||||
create_child,
|
||||
edit_child,
|
||||
list_children,
|
||||
reorder_children,
|
||||
set_child_active,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_children import ChildRecord, ChildrenDao
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
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):
|
||||
@@ -739,12 +739,12 @@ def test_deactivating_child_preserves_definitions_instances_events(
|
||||
async def _body(database):
|
||||
from datetime import date, timedelta
|
||||
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRulesDao,
|
||||
)
|
||||
@@ -831,7 +831,7 @@ def test_no_code_path_deletes_a_child_row() -> None:
|
||||
|
||||
import inspect
|
||||
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
|
||||
methods = {
|
||||
name
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"""The ``nestquest.complete_quest`` proxy to the API client (Feature 18).
|
||||
|
||||
The service is a thin proxy: a panel tap must reach the API's panel
|
||||
complete route through the entry's ONE shared API client (the same
|
||||
instance the coordinator polls and the SSE subscription reads), with
|
||||
exactly the payload contract the panel cards send (``instance_id``,
|
||||
``actor=panel``, ``actor_child_id``). The service performs NO local
|
||||
completion and fires NO local bus event — the API service emits the
|
||||
transition on its SSE stream and the integration's subscription
|
||||
re-fires it (firing it locally too would double-fire) — and it
|
||||
refreshes the coordinator after a successful call so the sensors
|
||||
reflect the completion immediately. Every failure (a typed
|
||||
:class:`NestQuestApiError`, an unconfigured client, a non-panel actor)
|
||||
surfaces as a :class:`HomeAssistantError`, token-free.
|
||||
|
||||
The scripted clients here stand in for the API service: recording and
|
||||
failing ``complete_instance`` implementations, no network, no aiohttp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.api_client import NestQuestApiError
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_PANEL_TOKEN,
|
||||
DOMAIN,
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
)
|
||||
|
||||
|
||||
class _RecordingApiClient(ScriptedPanelClient):
|
||||
"""Snapshot-only stand-in that RECORDS completion calls.
|
||||
|
||||
``complete_instance`` deliberately performs NO local write: the
|
||||
API service is remote, and the assertions below must be able to
|
||||
prove the service handler itself completed nothing locally.
|
||||
"""
|
||||
|
||||
|
||||
class _FailingApiClient(_RecordingApiClient):
|
||||
"""A stand-in whose completion fails the way the client types it."""
|
||||
|
||||
def __init__(self, error: NestQuestApiError, *outcomes) -> None:
|
||||
super().__init__(*outcomes)
|
||||
self.fail_complete_with = error
|
||||
|
||||
|
||||
def _empty_snapshot() -> dict:
|
||||
return {
|
||||
"today_iso": "2026-09-23",
|
||||
"cycle_day": 0,
|
||||
"children": [],
|
||||
}
|
||||
|
||||
|
||||
async def _setup_entry(hass, make_entry, client):
|
||||
"""Wire, script, and set up an entry against the scripted client."""
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={CONF_PANEL_TOKEN: "secret-panel-token"}),
|
||||
hass.registry,
|
||||
)
|
||||
set_coordinator_client(entry, client)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
return entry
|
||||
|
||||
|
||||
def _record_refreshes(entry) -> list[bool]:
|
||||
"""Replace the coordinator's refresh with a recording stand-in."""
|
||||
refreshes: list[bool] = []
|
||||
|
||||
async def _fake_refresh():
|
||||
refreshes.append(True)
|
||||
|
||||
entry.runtime_data.coordinator.async_refresh = _fake_refresh
|
||||
return refreshes
|
||||
|
||||
|
||||
async def test_complete_quest_reaches_the_shared_api_client(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A panel tap calls ``complete_instance`` on the entry's shared
|
||||
client with the tapped child, fires NO local event, and refreshes
|
||||
the coordinator immediately."""
|
||||
client = _RecordingApiClient(_empty_snapshot())
|
||||
entry = await _setup_entry(hass, make_entry, client)
|
||||
assert entry.runtime_data.coordinator.api_client is client, (
|
||||
"the service must proxy through the entry's ONE shared client"
|
||||
)
|
||||
refreshes = _record_refreshes(entry)
|
||||
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "panel",
|
||||
"actor_child_id": 1,
|
||||
},
|
||||
)
|
||||
|
||||
assert client.complete_calls == [(7, 1)]
|
||||
# No local firing: the completed (and day-complete) events arrive
|
||||
# over the SSE stream, re-fired by the subscription — never from
|
||||
# this handler.
|
||||
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
||||
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||
# The old immediate-update behaviour: the sensors' coordinator
|
||||
# refresh runs before the service call returns.
|
||||
assert refreshes == [True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "fragment"),
|
||||
[
|
||||
(
|
||||
NestQuestApiError(
|
||||
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
||||
"failed with HTTP 401",
|
||||
status=401,
|
||||
),
|
||||
"failed with HTTP 401",
|
||||
),
|
||||
(
|
||||
NestQuestApiError(
|
||||
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
||||
"transport failure: connection refused",
|
||||
status=None,
|
||||
),
|
||||
"transport failure",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_api_failure_surfaces_as_a_service_error(
|
||||
hass, make_entry, error, fragment
|
||||
) -> None:
|
||||
"""A typed API failure (non-2xx or transport) raises
|
||||
``HomeAssistantError`` carrying the typed message — which never
|
||||
includes the panel token — with nothing fired locally, and no
|
||||
refresh for a mutation that never happened."""
|
||||
client = _FailingApiClient(error, _empty_snapshot())
|
||||
entry = await _setup_entry(hass, make_entry, client)
|
||||
refreshes = _record_refreshes(entry)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match=fragment) as excinfo:
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "panel",
|
||||
"actor_child_id": 1,
|
||||
},
|
||||
)
|
||||
# The typed error is chained, and the service error carries the
|
||||
# typed message only — never the panel token.
|
||||
assert excinfo.value.__cause__ is error
|
||||
assert "secret-panel-token" not in str(excinfo.value)
|
||||
assert client.complete_calls == [(7, 1)]
|
||||
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
||||
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||
assert refreshes == []
|
||||
|
||||
|
||||
async def test_unconfigured_api_client_surfaces_as_a_service_error(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""An entry with no completion surface (the unconfigured-token
|
||||
stub the coordinator swaps in) fails the service clearly instead
|
||||
of reaching an attribute error."""
|
||||
from custom_components.nestquest.coordinator import _UnconfiguredApiClient
|
||||
|
||||
await _setup_entry(hass, make_entry, _UnconfiguredApiClient())
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match="NestQuest API is not configured"
|
||||
):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "panel",
|
||||
"actor_child_id": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_non_panel_actor_is_rejected_before_the_client(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""The API's panel route completes as the tapped child only: a
|
||||
user-actor call fails fast with a clear error and never reaches
|
||||
the client."""
|
||||
client = _RecordingApiClient(_empty_snapshot())
|
||||
await _setup_entry(hass, make_entry, client)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="actor must be 'panel'"):
|
||||
await hass.services.call(
|
||||
DOMAIN,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
{
|
||||
"instance_id": 7,
|
||||
"actor": "user",
|
||||
"actor_user_id": "admin-1",
|
||||
},
|
||||
context={"user_id": "admin-1"},
|
||||
)
|
||||
assert client.complete_calls == []
|
||||
@@ -8,27 +8,27 @@ import re
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.completion import (
|
||||
from custom_components.nestquest.completion import (
|
||||
append_event,
|
||||
complete_instance,
|
||||
instance_state,
|
||||
list_missed_for_child,
|
||||
uncomplete_instance,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import (
|
||||
EVENT_COMPLETED,
|
||||
EVENT_UNCOMPLETED,
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
from custom_components.nestquest.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRulesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -311,7 +311,7 @@ def test_now_pins_occurred_at_utc(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_module_never_exposes_update_or_delete() -> None:
|
||||
import custom_components.nestquest.core.completion as completion
|
||||
import custom_components.nestquest.completion as completion
|
||||
|
||||
public = {
|
||||
name
|
||||
|
||||
@@ -156,13 +156,23 @@ def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
|
||||
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:
|
||||
"""A submit with user input stores it as-is; no admin copy is
|
||||
invented (the allowlist lives in the API service's own store since
|
||||
the DB removal)."""
|
||||
flow = make_flow(hass)
|
||||
result = await flow.async_step_user({"update_interval": 60})
|
||||
result = await flow.async_step_user({"horizon_days": 7})
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"] == {"update_interval": 60}
|
||||
assert result["data"] == {"horizon_days": 7}
|
||||
|
||||
+250
-702
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,9 @@ Covers the contracts from task 4dd8f870 plus the review fixes:
|
||||
test feeds the scanner each bypass shape and asserts it flags them.
|
||||
- A settings object constructed with NON-default ``horizon_days`` and
|
||||
``day_rollover_time`` drives a materialization whose resulting window
|
||||
matches the non-default horizon — via the core ``materialize`` path
|
||||
(the integration path it used to share is gone with the integration's
|
||||
database).
|
||||
matches the non-default horizon — via BOTH the core ``materialize``
|
||||
path and the INTEGRATION path (``_run_horizon_materialization`` +
|
||||
``_register_day_rollover_listener``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,6 +29,7 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
import custom_components.nestquest.core as _core_mod
|
||||
from conftest import freeze_nestquest_clock
|
||||
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
@@ -740,7 +741,7 @@ def test_settings_module_has_no_ha_imports() -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Materialization over a non-default horizon (core path).
|
||||
# Materialization over a non-default horizon: core path + integration path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -827,3 +828,85 @@ async def test_materialize_over_non_default_horizon(hass, tmp_path) -> None:
|
||||
) == []
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_integration_path_threads_settings(
|
||||
hass, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""The integration path threads ``settings`` through horizon_window + rollover.
|
||||
|
||||
``_run_horizon_materialization`` calls
|
||||
``settings.horizon_window(today)`` (so the helper is real and shared,
|
||||
not recomputed inline), and ``_register_day_rollover_listener``
|
||||
registers at ``settings.day_rollover_hour_minute``. This runs the
|
||||
INTEGRATION path with ``horizon_days=3`` and
|
||||
``day_rollover_time='03:30'`` and asserts the resulting instance
|
||||
window matches [today, today+3] AND the registered hour/minute are 3/30.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
ScheduleRule,
|
||||
)
|
||||
|
||||
# Freeze the integration clock so "today" is deterministic (the
|
||||
# hass fixture's config.time_zone is UTC, so 2026-03-01 12:00 UTC
|
||||
# localizes to 2026-03-01).
|
||||
freeze_nestquest_clock(
|
||||
monkeypatch, datetime.datetime(2026, 3, 1, 12, 0, tzinfo=datetime.timezone.utc)
|
||||
)
|
||||
|
||||
database = await _prepare(tmp_path / "nq.db", hass.async_add_executor_job)
|
||||
try:
|
||||
settings = NestQuestSettings(horizon_days=3, day_rollover_time="03:30")
|
||||
today = datetime.date(2026, 3, 1)
|
||||
|
||||
NOW = "2026-03-01T12:00:00+00:00"
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date=today.isoformat()
|
||||
),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
|
||||
# Integration path: the window comes from settings.horizon_window.
|
||||
await nestquest._run_horizon_materialization(hass, database, settings)
|
||||
|
||||
end = today + datetime.timedelta(days=3)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, today.isoformat(), end.isoformat()
|
||||
)
|
||||
dates = sorted({r.due_date for r in records})
|
||||
assert dates == [
|
||||
(today + datetime.timedelta(days=i)).isoformat()
|
||||
for i in range(4)
|
||||
]
|
||||
beyond = (end + datetime.timedelta(days=1)).isoformat()
|
||||
far = (end + datetime.timedelta(days=10)).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, beyond, far
|
||||
) == []
|
||||
|
||||
# The rollover listener registers at the configured hour/minute.
|
||||
remove = nestquest._register_day_rollover_listener(
|
||||
hass, database, settings
|
||||
)
|
||||
try:
|
||||
reg = hass.time_change.registrations[-1]
|
||||
assert reg["hour"] == 3
|
||||
assert reg["minute"] == 30
|
||||
finally:
|
||||
remove()
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
+25
-65
@@ -10,22 +10,17 @@ child's ``next_present``, a due-timed quest's ``overdue`` under a
|
||||
pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload
|
||||
dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and
|
||||
``missed`` omission) are all exercised as literals.
|
||||
|
||||
The database is a standalone core one (the integration owns no
|
||||
database since Feature 18's DB removal); the closing round-trip pins
|
||||
that the integration's coordinator reconstructs the SAME dataclasses
|
||||
from the route's payload.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from conftest import set_coordinator_client, wire_entry_to_registry
|
||||
from conftest import wire_entry_to_registry
|
||||
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
from custom_components.nestquest.core.children import create_child
|
||||
from custom_components.nestquest.core.completion import complete_instance
|
||||
from custom_components.nestquest.children import create_child
|
||||
from custom_components.nestquest.completion import complete_instance
|
||||
from custom_components.nestquest.core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
@@ -33,17 +28,14 @@ from custom_components.nestquest.core.snapshot import (
|
||||
build_snapshot,
|
||||
instance_payload,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.materialize import materialize
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.quest_definitions import create_quest_definition
|
||||
from custom_components.nestquest.core.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.core.settings import NestQuestSettings
|
||||
from custom_components.nestquest.materialize import materialize
|
||||
from custom_components.nestquest.quest_definitions import create_quest_definition
|
||||
from custom_components.nestquest.recurrence import ScheduleRule
|
||||
|
||||
|
||||
async def test_instance_payload_shape_and_missed_omission() -> None:
|
||||
@@ -113,7 +105,7 @@ async def test_instance_payload_shape_and_missed_omission() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> None:
|
||||
async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None:
|
||||
"""The builder produces the documented shape as literals, pinned in time.
|
||||
|
||||
Ada is AWAY today (a 2-week schedule whose absent week 0 covers today,
|
||||
@@ -125,18 +117,15 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
|
||||
scheduled active child is Ada, whose 2-week cycle anchored one day
|
||||
before today puts ``cycle_day`` at 2.
|
||||
"""
|
||||
# A standalone core database (the integration owns none): the
|
||||
# builder and its seed run against it through the executor.
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "snapshot.db")
|
||||
await apply_migrations(database)
|
||||
settings = NestQuestSettings()
|
||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
database = entry.runtime_data.database
|
||||
settings = entry.runtime_data.settings
|
||||
|
||||
# Single clock read for the seed/snapshot anchor (no midnight skew);
|
||||
# the coordinator no longer owns a clock read (the API serves the
|
||||
# snapshot), so the test resolves the HA-local zone directly.
|
||||
# Single clock read for the seed/snapshot anchor (no midnight skew).
|
||||
today = coordinator._local_now().date()
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
# Pin the build instant at 12:00 local: past Ada's 10:00 due time
|
||||
# (overdue) and before Bo's 17:00, and the completion moment for Bo.
|
||||
pinned_now = datetime.datetime(
|
||||
@@ -305,42 +294,13 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
|
||||
== "missed"
|
||||
)
|
||||
|
||||
# REQUIRED wrapper tie-in: since Feature 18 the coordinator's
|
||||
# refresh polls the snapshot through its API client, so pinning the
|
||||
# client to serve the payload of the SAME snapshot built with
|
||||
# pinned_now must reproduce the builder's snapshot byte-for-byte.
|
||||
# The dataclasses are frozen, so == is a full deep compare — a
|
||||
# reconstruction that dropped or reshaped a field would diverge
|
||||
# here even though the literal assertions above still pass.
|
||||
class _PinnedPayloadClient:
|
||||
async def get_snapshot(self):
|
||||
return {
|
||||
"today_iso": built.today_iso,
|
||||
"cycle_day": built.cycle_day,
|
||||
"children": [
|
||||
{
|
||||
"child_id": child.child_id,
|
||||
"child_name": child.child_name,
|
||||
"present": child.present,
|
||||
"next_present": child.next_present,
|
||||
"due_today": child.due_today,
|
||||
"completed_today": child.completed_today,
|
||||
"remaining_today": child.remaining_today,
|
||||
"completion_pct": child.completion_pct,
|
||||
"instances": instance_payload(
|
||||
child.instances, include_missed=False
|
||||
),
|
||||
}
|
||||
for child in built.children
|
||||
],
|
||||
}
|
||||
|
||||
entry = wire_entry_to_registry(
|
||||
make_entry(data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}),
|
||||
hass.registry,
|
||||
)
|
||||
set_coordinator_client(entry, _PinnedPayloadClient())
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
# REQUIRED wrapper tie-in: the coordinator's _async_update_data is a
|
||||
# thin WRAPPER over build_snapshot, so pinning its clock to the SAME
|
||||
# pinned_now the builder was called with must reproduce the builder's
|
||||
# snapshot byte-for-byte. The dataclasses are frozen, so == is a full
|
||||
# deep compare — a wrapper that mis-passed today vs now, or stopped
|
||||
# calling the builder, would diverge here even though the literal
|
||||
# assertions above still pass.
|
||||
monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now)
|
||||
await coordinator.async_refresh()
|
||||
assert coordinator.data == built
|
||||
await database.close()
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
"""Cross-process safety of the missed-sweep watermark claim.
|
||||
|
||||
Feature 11's sweep guards its announcement with the watermark; the
|
||||
original guard was a read-then-set inside a per-PROCESS asyncio lock,
|
||||
so two PROCESSES sharing one SQLite file (the HA integration's
|
||||
rollover sweep and the API service's scheduler) could both read the
|
||||
old watermark and both announce the same missed batch. These tests
|
||||
pin the fix:
|
||||
|
||||
- :meth:`MetaStateDao.claim` is the atomic, database-level primitive:
|
||||
one conditional upsert whose ``rowcount`` says whether THIS caller
|
||||
changed the row. The first claimant of a value wins; a second
|
||||
claimant of the same value — even on an INDEPENDENT connection to
|
||||
the same file, the shape two processes produce — loses.
|
||||
- :func:`run_missed_sweep` gates the announcement on that claim: two
|
||||
independent ``NestQuestDatabase`` wrappers (two processes' shape)
|
||||
racing one seeded database yields exactly one announcer.
|
||||
- The documented sweep semantics are unchanged: a same-night rerun
|
||||
announces nothing, and a run after days of downtime announces the
|
||||
accumulated ``[watermark, today)`` window exactly once.
|
||||
|
||||
Everything here runs against the bundled HA-free core copy
|
||||
(``custom_components.nestquest.core``) with a plain
|
||||
``asyncio.to_thread`` executor — no Home Assistant, no API server.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.dao_meta import MetaStateDao
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.core.children import create_child
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.materialize import materialize
|
||||
from custom_components.nestquest.core.recurrence import ScheduleRule
|
||||
from custom_components.nestquest.core.sweep import (
|
||||
SWEEP_WATERMARK_KEY,
|
||||
run_missed_sweep,
|
||||
)
|
||||
|
||||
#: One missed event per unswept past-due instance — the sweep's only
|
||||
#: event type.
|
||||
EVENT_QUEST_MISSED = "nestquest_quest_missed"
|
||||
|
||||
|
||||
async def _executor(fn, *args, **kwargs):
|
||||
"""Run a synchronous callable on a worker thread and await it.
|
||||
|
||||
The :class:`NestQuestDatabase` executor contract (the shape of
|
||||
``hass.async_add_executor_job``), so sqlite3 calls never block the
|
||||
event loop.
|
||||
"""
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
|
||||
|
||||
async def _open_database(db_path: str) -> NestQuestDatabase:
|
||||
"""Open (without migrating) one independent connection wrapper."""
|
||||
database = NestQuestDatabase(_executor)
|
||||
await database.open(db_path)
|
||||
return database
|
||||
|
||||
|
||||
async def _seed_missed_household(
|
||||
db_path: str, today: datetime.date
|
||||
) -> SimpleNamespace:
|
||||
"""Seed a household with two past-due open instances on ``db_path``.
|
||||
|
||||
One child, two quests whose ONLY instances are due YESTERDAY
|
||||
(materialized with ``today`` pinned to yesterday, ``end_date``
|
||||
closing the rules so the later today-walk cannot duplicate them).
|
||||
Seeding uses its OWN connection, opened and closed here, so the
|
||||
connections the tests race with start fresh.
|
||||
"""
|
||||
database = await _open_database(db_path)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
today_iso = today.isoformat()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
yesterday_iso = yesterday.isoformat()
|
||||
rule = ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": yesterday_iso,
|
||||
"end_date": yesterday_iso,
|
||||
}
|
||||
)
|
||||
ada = await create_child(database, "Ada", sort_order=0)
|
||||
await create_quest_definition(
|
||||
database, "Stale chore", rule, [ada.id], [("morning", "09:00")]
|
||||
)
|
||||
await create_quest_definition(
|
||||
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
|
||||
)
|
||||
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
|
||||
# A quest due TODAY (created after the yesterday walk so it has
|
||||
# no past-due instance): the post-downtime catch-up run's
|
||||
# target — inside [watermark=today, later_today) but not
|
||||
# inside the day-one sweep's window.
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Recoverable chore",
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("afternoon", "17:00")],
|
||||
)
|
||||
await materialize(database, today_iso, today_iso, today=today)
|
||||
instances = QuestInstancesDao(database)
|
||||
rows = await instances.list_by_child_and_date(ada.id, yesterday_iso)
|
||||
assert len(rows) == 2
|
||||
today_rows = await instances.list_by_child_and_date(
|
||||
ada.id, today_iso
|
||||
)
|
||||
assert len(today_rows) == 1
|
||||
return SimpleNamespace(
|
||||
child=ada,
|
||||
instances=rows,
|
||||
recoverable_instance=today_rows[0],
|
||||
yesterday_iso=yesterday_iso,
|
||||
today_iso=today_iso,
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
# --- the claim primitive: atomic at the database level ------------------
|
||||
|
||||
|
||||
async def test_claim_first_caller_wins_and_second_same_value_loses(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""claim() is a conditional upsert: first True, same-value second False.
|
||||
|
||||
A different value always wins (an advance re-arms the check), and
|
||||
the stored value follows the winner.
|
||||
"""
|
||||
database = await _open_database(temp_db_path)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
dao = MetaStateDao(database)
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) is None
|
||||
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is True
|
||||
# Same value again: the row is already there — nothing changed.
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-01") is False
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-01"
|
||||
# A DIFFERENT value is an advance, not a re-claim: it wins.
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is True
|
||||
assert await dao.get(SWEEP_WATERMARK_KEY) == "2026-09-02"
|
||||
assert await dao.claim(SWEEP_WATERMARK_KEY, "2026-09-02") is False
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_claim_is_atomic_across_independent_connections(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Two connections to one file cannot both claim the same value.
|
||||
|
||||
This is the two-process shape: independent sqlite3 connections (one
|
||||
per ``NestQuestDatabase`` wrapper), racing the SAME key on the SAME
|
||||
event loop with no shared lock. The single conditional upsert is
|
||||
serialized by SQLite itself, so exactly one caller's rowcount is 1.
|
||||
"""
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
await apply_migrations(first)
|
||||
won = await asyncio.gather(
|
||||
MetaStateDao(first).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
|
||||
MetaStateDao(second).claim(SWEEP_WATERMARK_KEY, "2026-09-01"),
|
||||
)
|
||||
assert sorted(won) == [False, True]
|
||||
# One stored row, holding the winner's value.
|
||||
assert await MetaStateDao(first).get(SWEEP_WATERMARK_KEY) == (
|
||||
"2026-09-01"
|
||||
)
|
||||
assert await MetaStateDao(second).get(SWEEP_WATERMARK_KEY) == (
|
||||
"2026-09-01"
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
|
||||
|
||||
# --- the sweep: the announcement is gated by the atomic claim -----------
|
||||
|
||||
|
||||
async def test_two_process_shape_race_exactly_one_announces(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Two independent connections racing the sweep: ONE announcer.
|
||||
|
||||
Each wrapper carries its own connection AND its own per-process
|
||||
sweep lock, so nothing in-process serialises the two runs — the
|
||||
only guard left is the database-level claim. Both read the unset
|
||||
watermark and both build the events, but exactly one claim wins:
|
||||
one run returns the missed events, the loser returns [].
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
seed = await _seed_missed_household(temp_db_path, today)
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
results = await asyncio.gather(
|
||||
run_missed_sweep(first, today=today),
|
||||
run_missed_sweep(second, today=today),
|
||||
)
|
||||
announcers = [r for r in results if r]
|
||||
assert len(announcers) == 1
|
||||
event_type, payload = announcers[0][0]
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["due_date"] == seed.yesterday_iso
|
||||
# Both instances of the seeded window, one event each.
|
||||
assert len(announcers[0]) == 2
|
||||
losers = [r for r in results if not r]
|
||||
assert losers == [[]]
|
||||
# The watermark holds the run's date for whoever looks next.
|
||||
assert (
|
||||
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
|
||||
== seed.today_iso
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
|
||||
|
||||
async def test_same_night_rerun_announces_nothing(temp_db_path: str) -> None:
|
||||
"""A second sweep for the same date is an empty no-op (the watermark)."""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
await _seed_missed_household(temp_db_path, today)
|
||||
database = await _open_database(temp_db_path)
|
||||
try:
|
||||
first = await run_missed_sweep(database, today=today)
|
||||
assert len(first) == 2
|
||||
second = await run_missed_sweep(database, today=today)
|
||||
assert second == []
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_post_downtime_run_announces_accumulated_window_once(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""After days of downtime the catch-up run sweeps the window once.
|
||||
|
||||
The first run sweeps yesterday's instances (watermark := today);
|
||||
``today`` then jumps two days and the catch-up run — on a
|
||||
DIFFERENT connection, the two-process shape — announces the
|
||||
accumulated ``[watermark, later_today)`` window exactly once, and
|
||||
a rerun on the original connection is silent again.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
later_today = today + datetime.timedelta(days=2)
|
||||
seed = await _seed_missed_household(temp_db_path, today)
|
||||
first = await _open_database(temp_db_path)
|
||||
second = await _open_database(temp_db_path)
|
||||
try:
|
||||
# Day one: yesterday's two instances fire once.
|
||||
day_one = await run_missed_sweep(first, today=today)
|
||||
assert len(day_one) == 2
|
||||
assert all(
|
||||
payload["due_date"] == seed.yesterday_iso
|
||||
for _, payload in day_one
|
||||
)
|
||||
|
||||
# The downtime catch-up: the window is [watermark, later_today).
|
||||
catchup = await run_missed_sweep(second, today=later_today)
|
||||
assert len(catchup) == 1
|
||||
event_type, payload = catchup[0]
|
||||
assert event_type == EVENT_QUEST_MISSED
|
||||
assert payload["due_date"] == seed.today_iso
|
||||
assert payload["instance_id"] == seed.recoverable_instance.id
|
||||
assert payload["child_id"] == seed.child.id
|
||||
|
||||
# A further rerun announces nothing.
|
||||
assert await run_missed_sweep(first, today=later_today) == []
|
||||
assert (
|
||||
await MetaStateDao(first).get(SWEEP_WATERMARK_KEY)
|
||||
== later_today.isoformat()
|
||||
)
|
||||
finally:
|
||||
await first.close()
|
||||
await second.close()
|
||||
@@ -6,15 +6,15 @@ import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.core.dao_children import (
|
||||
from custom_components.nestquest.dao_children import (
|
||||
AdminUserRecord,
|
||||
AdminUsersDao,
|
||||
ChildRecord,
|
||||
ChildrenDao,
|
||||
)
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.schema import SCHEMA_V1_STATEMENTS
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
|
||||
def _run(coro):
|
||||
@@ -504,6 +504,9 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
|
||||
"tests/test_dao_rules.py", # rules/definitions guard, own scope
|
||||
"tests/test_dao_presence.py", # presence guard, own scope
|
||||
"tests/test_dao_instances.py", # instances guard, own scope
|
||||
"tests/test_startup_db.py", # row-count probe of the children
|
||||
# table only (proving a fresh setup loads); guard below covers
|
||||
# everything else
|
||||
}
|
||||
sql_pattern = re.compile(
|
||||
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+"
|
||||
@@ -555,6 +558,32 @@ def _strip_function_spans(text: str, names: set[str]) -> str:
|
||||
for number, line in enumerate(lines, start=1)
|
||||
if number not in excluded
|
||||
)
|
||||
|
||||
|
||||
def test_startup_db_test_file_uses_dao_elsewhere() -> None:
|
||||
"""Compensating self-scan for the startup-test exemption: outside
|
||||
the single sanctioned COUNT(*) probe, test_startup_db.py must go
|
||||
through the DAO for children/admin_users data access.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
startup_test = Path(__file__).parent / "test_startup_db.py"
|
||||
stripped = _strip_function_spans(
|
||||
startup_test.read_text(),
|
||||
{"test_setup_with_valid_file_preserves_rows"},
|
||||
)
|
||||
sql_pattern = re.compile(
|
||||
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
|
||||
r"FROM|JOIN)\s+[`'\"]*(\[)?(children|admin_users)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert sql_pattern.search(stripped) is None, (
|
||||
"test_startup_db.py must go through the DAO for these tables "
|
||||
"outside its sanctioned row-count probe"
|
||||
)
|
||||
|
||||
|
||||
def test_dao_matrix_test_file_raw_probes_are_only_constraint_probes() -> None:
|
||||
"""Compensating self-scan for the matrix-file exemption, structurally
|
||||
exact: parse the two sanctioned functions' ASTs and require that
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user