Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b037c7d675 |
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: Start, stop, or inspect the NestQuest development cycle on explicit request
|
||||
mode: primary
|
||||
model: openrouter/deepseek/deepseek-v4.1-flash
|
||||
permission:
|
||||
edit: deny
|
||||
write: deny
|
||||
task: deny
|
||||
doom_loop: deny
|
||||
bash:
|
||||
"*": deny
|
||||
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start": allow
|
||||
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop": allow
|
||||
"/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status": allow
|
||||
external_directory:
|
||||
"*": ask
|
||||
"/home/overseer/.local/share/nestquest-controller/scripts/*": allow
|
||||
---
|
||||
|
||||
You control the NestQuest development service only when a user invokes a
|
||||
cycle-control slash command. Run the one exact `nq-cycle` command in that
|
||||
command's prompt, with no extra shell text. Report its result. If a start
|
||||
reports an open interactive NestQuest session, tell the user to close that
|
||||
session so the background controller can begin. Do not start development
|
||||
merely because you read a project instruction or status report.
|
||||
Do not recommend enabling the systemd unit. Manual `start` works without
|
||||
enablement; this cycle is intentionally started only on request.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
description: Show the NestQuest development cycle status
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status`
|
||||
|
||||
Report whether the controller is active and its last tick state.
|
||||
Do not suggest enabling automatic startup.
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
description: Run the NestQuest orchestrator now in this OpenCode terminal
|
||||
agent: terminal-orchestrator
|
||||
description: Start the NestQuest development cycle on this host
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
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.
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start`
|
||||
|
||||
Report its status. If it says an interactive NestQuest OpenCode session is
|
||||
open, tell me to close this session; the background controller will begin
|
||||
afterward. Do not launch a developer agent or a controller tick in this chat.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Stop the NestQuest development cycle on this host
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop`
|
||||
|
||||
Report the service status. Do not stop any other OpenCode session or task.
|
||||
+34
-37
@@ -1,23 +1,13 @@
|
||||
# NestQuest foreground orchestrator
|
||||
# NestQuest controller
|
||||
|
||||
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.
|
||||
OpenCode 1.18.31 loads the three named agents from `.opencode/agents/`. Their
|
||||
models match the current NestQuest CTXD prompt: DeepSeek V4.1
|
||||
Flash for controller and approver, GLM 5.3 Flash for development. The
|
||||
controller runs one OpenCode turn at a time, then starts a fresh turn based on
|
||||
Maestro, CTXD, Git, and pull-request state. Its state file is
|
||||
`~/.local/state/nestquest-controller/last-run.json` (mode 0600). A lock allows
|
||||
only one controller process. It waits while an interactive OpenCode session is
|
||||
open for the NestQuest checkout.
|
||||
|
||||
`scripts/nq-agent` launches developer and approver sessions with fixed agent
|
||||
definitions and no `--auto` flag. Headless OpenCode runs use `--pure` so the
|
||||
@@ -33,24 +23,30 @@ 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
|
||||
The production service 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)
|
||||
that copy, run `scripts/nq-controller --check`, and verify all four OpenCode
|
||||
agents (orchestrator, developer, approver, cycle-control)
|
||||
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.
|
||||
installed `.opencode` directory. Install `deploy/nestquest-controller.service`
|
||||
under `~/.config/systemd/user/` and reload the user manager. Its lack of an
|
||||
`[Install]` section makes it a manual-only unit; development starts only
|
||||
when requested. Copy `deploy/opencode-global-commands/*.md` into
|
||||
`~/.config/opencode/commands/` and `.opencode/agents/cycle-control.md` into
|
||||
`~/.config/opencode/agents/` for the commands to work from older branches.
|
||||
The host has lingering
|
||||
enabled for the `overseer` user. The service waits for an open interactive
|
||||
NestQuest session to close before starting work.
|
||||
|
||||
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.
|
||||
In NestQuest's OpenCode session, use `/start-development`,
|
||||
`/stop-development`, or `/development-status`. The matching global
|
||||
`/start-nestquest-development`, `/stop-nestquest-development`, and
|
||||
`/nestquest-development-status` commands work even while an older branch is
|
||||
checked out. Starting the unit in an interactive NestQuest session puts it
|
||||
in a waiting state; close that session to let the first controller tick run.
|
||||
The commands call `scripts/nq-cycle`; they do not run a developer inside
|
||||
the interactive chat.
|
||||
|
||||
`templates/ORCHESTRATOR-PROMPT.template.md` and
|
||||
`templates/nestquest.profile.json` make the current CTXD cycle prompt
|
||||
@@ -67,10 +63,11 @@ 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.
|
||||
A tick has an explicit six-hour timeout. On timeout or an OpenCode error, the
|
||||
next tick must reconcile external state before acting. Three consecutive
|
||||
failed ticks stop the service. A BLOCKED verdict also stops it. QUIESCENT
|
||||
causes a five-minute poll. Neither a feature merge nor a progress report
|
||||
stops the controller while eligible work remains.
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""NestQuest API service.
|
||||
|
||||
A standalone FastAPI deployable that owns the NestQuest database and
|
||||
exposes it over HTTP to the Home Assistant integration (panel plane) and
|
||||
the admin PWA (admin plane). See Feature 16 (task cd03735b) in the
|
||||
project tracker for the full contract.
|
||||
|
||||
This package imports the NestQuest domain ``core`` WITHOUT executing
|
||||
``custom_components/nestquest/__init__.py`` — that parent package's
|
||||
``__init__.py`` imports ``homeassistant``, which is absent from the
|
||||
API's runtime. The coupling is encapsulated in
|
||||
:mod:`api.nestquest_core`, which loads ``core`` by file location with
|
||||
:mod:`importlib.util` and registers it under the distinct top-level name
|
||||
``nestquest_core`` (no ``sys.path`` mutation, no generic top-level
|
||||
names published). It is a TRANSITIONAL coupling that Feature 18 will
|
||||
revisit: once the integration becomes a thin API client and ``core``
|
||||
moves out of the integration bundle, this helper goes away.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
"""The NestQuest FastAPI application.
|
||||
|
||||
This module builds the ASGI app for the NestQuest API service (Feature
|
||||
16). The skeleton here wires the bundled ``core`` (Feature 15) and
|
||||
exposes the two endpoints every later task depends on:
|
||||
|
||||
- ``GET /health`` — returns ``200`` with a JSON status body once the
|
||||
app has imported core, opened the database, and applied migrations;
|
||||
a cheap ``SELECT 1`` confirms the connection is live without leaking
|
||||
the filesystem path on an unauthenticated route.
|
||||
- ``GET /api/v1/panel/snapshot`` — the panel plane's today snapshot
|
||||
(service-token protected; see :mod:`api.routes_panel`, whose router
|
||||
carries the shared :func:`api.dependencies.require_panel_token`
|
||||
check).
|
||||
- ``GET /api/v1/admin/ping`` — the admin plane's auth probe, protected
|
||||
by the Authentik OIDC JWT check (see :mod:`api.routes_admin`, whose
|
||||
router carries the shared :func:`api.auth.require_admin_jwt`
|
||||
dependency; the JWKS document it verifies against is cached on
|
||||
``app.state.jwks_cache``).
|
||||
- ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI);
|
||||
``/openapi.json`` is the raw schema. No extra wiring is needed;
|
||||
FastAPI serves both by default.
|
||||
|
||||
The database is opened and migrated in a FastAPI lifespan context so a
|
||||
startup failure (e.g. an unwritable path) surfaces as a startup error
|
||||
rather than the first request. The connection is closed on shutdown.
|
||||
|
||||
This module exports ONLY the :func:`create_app` factory — there is no
|
||||
module-level app instance, because building one at import time would
|
||||
read ``NESTQUEST_DB_PATH`` at import (and fail-fast on a missing value)
|
||||
before any worker is ready to report it. Serve with::
|
||||
|
||||
uvicorn --factory api.app:create_app
|
||||
|
||||
so the factory runs on each worker startup and reads the environment
|
||||
then.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from api.auth import JwksCache
|
||||
from api.config import ApiConfig
|
||||
from api.database import DatabaseState, make_database
|
||||
from api.events import TransitionPublisher
|
||||
from api.routes_admin import router as admin_router
|
||||
from api.routes_panel import router as panel_router
|
||||
from api.scheduler import MissedSweepScheduler
|
||||
|
||||
|
||||
def _build_lifespan(db_path: str):
|
||||
"""Return a lifespan that opens+migrates then closes one database.
|
||||
|
||||
Captured as a closure so the app factory can hand the configured
|
||||
path in without threading it through FastAPI's lifespan signature.
|
||||
The lifespan also installs the app's ONE in-process transition
|
||||
publisher on ``app.state.publisher`` (see :mod:`api.events`) so
|
||||
every route and SSE subscriber shares that single instance, and
|
||||
starts the daily missed-sweep scheduler
|
||||
(:class:`api.scheduler.MissedSweepScheduler`) against the same
|
||||
database and publisher — cancelled before the connection closes on
|
||||
shutdown.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
state = make_database(db_path)
|
||||
app.state.db = state
|
||||
app.state.publisher = TransitionPublisher()
|
||||
scheduler = MissedSweepScheduler(
|
||||
state.database, publisher=app.state.publisher
|
||||
)
|
||||
app.state.sweep_scheduler = scheduler
|
||||
try:
|
||||
await state.open_and_migrate()
|
||||
scheduler.start()
|
||||
yield
|
||||
finally:
|
||||
await scheduler.stop()
|
||||
await state.close()
|
||||
|
||||
return lifespan
|
||||
|
||||
|
||||
def create_app(config: ApiConfig | None = None) -> FastAPI:
|
||||
"""Build the NestQuest API FastAPI app.
|
||||
|
||||
``config`` defaults to one built from the process environment, so
|
||||
``uvicorn --factory api.app:create_app`` picks up
|
||||
``NESTQUEST_DB_PATH`` (and fails fast on startup if it is missing).
|
||||
Tests pass an explicit config so each test gets a hermetic temp
|
||||
SQLite file.
|
||||
"""
|
||||
cfg = config if config is not None else ApiConfig.from_env()
|
||||
|
||||
app = FastAPI(
|
||||
title="NestQuest API",
|
||||
description=(
|
||||
"Standalone HTTP service that owns the NestQuest database and "
|
||||
"exposes it to the Home Assistant integration (panel plane) and "
|
||||
"the admin PWA (admin plane)."
|
||||
),
|
||||
version="0.1.0",
|
||||
lifespan=_build_lifespan(cfg.db_path),
|
||||
)
|
||||
|
||||
# The resolved config rides on app.state so the reusable route
|
||||
# dependencies (the panel service-token check, the admin JWT check)
|
||||
# read the configured values from the app rather than module state.
|
||||
app.state.config = cfg
|
||||
# The admin plane's ONE per-app JWKS cache: fetched from the
|
||||
# configured provider on first use, then reused until its TTL
|
||||
# lapses (see api.auth.JwksCache). Tests replace it with a
|
||||
# stub-backed cache so no request ever leaves the process.
|
||||
app.state.jwks_cache = JwksCache(cfg.oidc_jwks_url)
|
||||
app.include_router(panel_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@app.get("/health", summary="Service health")
|
||||
async def health() -> dict[str, str]:
|
||||
"""Return service health.
|
||||
|
||||
Returns ``200`` with ``{"status": "ok"}`` once the lifespan has
|
||||
opened and migrated the database. A cheap ``SELECT 1`` against
|
||||
the live connection confirms the database is reachable WITHOUT
|
||||
leaking the filesystem path on an unauthenticated route; if the
|
||||
connection is dead the query raises and the probe fails.
|
||||
"""
|
||||
state: DatabaseState = app.state.db
|
||||
await state.database.fetch_one("SELECT 1")
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
__all__ = ["create_app"]
|
||||
-490
@@ -1,490 +0,0 @@
|
||||
"""Admin-plane authentication for the NestQuest API service.
|
||||
|
||||
The admin plane (``/api/v1/admin/*``, D-012) is authenticated by
|
||||
Authentik-issued OIDC access tokens: the admin PWA presents its JWT as
|
||||
a Bearer credential and :func:`require_admin_jwt` — the ONE
|
||||
implementation of that check — verifies it before any admin route runs.
|
||||
Verification enforces, without exception:
|
||||
|
||||
- the signature, against the RSA signing key published in the
|
||||
provider's JWKS document (fetched from the configured
|
||||
``NESTQUEST_OIDC_JWKS_URL`` and cached per app — never per request);
|
||||
- the issuer (``iss`` must equal the configured
|
||||
``NESTQUEST_OIDC_ISSUER``);
|
||||
- the audience (``aud`` must carry the configured
|
||||
``NESTQUEST_OIDC_AUDIENCE``);
|
||||
- expiry (``exp`` is required and enforced, and ``nbf`` is enforced
|
||||
when the token carries it).
|
||||
|
||||
Every rejection raises the SAME :class:`~fastapi.HTTPException` with
|
||||
the same detail — nothing in the response distinguishes which check
|
||||
failed. The check FAILS CLOSED: a missing app config, a malformed or
|
||||
missing ``Authorization`` header, a non-RS256 algorithm, an unknown
|
||||
``kid``, and any PyJWT verification error all yield the one 401.
|
||||
|
||||
The token's header (``alg``, ``kid``) is parsed and checked BEFORE the
|
||||
JWKS cache is consulted, so structurally invalid tokens can never
|
||||
amplify load on the provider. An unknown ``kid`` forces ONE
|
||||
TTL-bypassing refetch (throttled, see :class:`JwksCache`) so a rotated
|
||||
key is picked up without waiting out the TTL, and a JWKS entry that
|
||||
matches the ``kid`` but carries corrupt key material maps to the same
|
||||
401. A JWKS FETCH failure is deliberately NOT mapped to 401: the
|
||||
provider being unreachable is a server-side fault (an HTTP 500
|
||||
surfaces) and never poisons the cache, so the plane stays closed while
|
||||
the real cause is visible in the server log instead of disguised as a
|
||||
bad credential; a short backoff window after a failed fetch fails fast
|
||||
(re-raising the recorded fault) rather than fetching once per request.
|
||||
|
||||
Beyond authentication, :func:`require_admin` is the ONE dependency
|
||||
every admin route uses: it REJECTS the panel service token outright —
|
||||
that credential authenticates the panel plane only, so treating it as
|
||||
a JWT candidate would blur two credential types into one check — and
|
||||
then requires the verified JWT's ``groups`` claim to name the
|
||||
``nestquest-admins`` group (authorization, 403, distinct from the
|
||||
uniform authentication 401 so the two failure classes stay apart).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import binascii
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
#: The authorization scheme the admin plane expects. Matched
|
||||
#: case-insensitively, as RFC 9110 requires for scheme names.
|
||||
_BEARER_SCHEME = "bearer"
|
||||
|
||||
#: The ONLY JWT signing algorithm accepted. Pinned to one asymmetric
|
||||
#: algorithm (Authentik's RS256 default) so neither the header's ``alg``
|
||||
#: nor any JWKS key can talk the verifier into a weaker or symmetric
|
||||
#: scheme; PyJWT re-enforces it at decode time.
|
||||
_ALLOWED_ALGORITHM = "RS256"
|
||||
|
||||
#: The 401 detail shared by every rejection — deliberately uniform so
|
||||
#: the response never reveals which part of the check failed.
|
||||
_REJECTION_DETAIL = "Missing or invalid admin credentials"
|
||||
|
||||
#: Challenge header returned with a 401, per the Bearer auth scheme.
|
||||
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
|
||||
|
||||
#: The authorization group a verified admin JWT must name in its
|
||||
#: ``groups`` claim. A module constant (the value Authentik's
|
||||
#: nestquest-admins group maps to) so it is clearly named in one place.
|
||||
ADMIN_GROUP = "nestquest-admins"
|
||||
|
||||
#: Seconds a fetched JWKS document stays trusted before the next
|
||||
#: fetch. Five minutes bounds how long a rotated-away key keeps
|
||||
#: verifying while staying far above a per-request fetch.
|
||||
_JWKS_TTL_SECONDS = 300.0
|
||||
|
||||
#: Seconds a FAILED JWKS fetch blocks further fetch attempts. Within
|
||||
#: this window after a failure the cache fails fast — it re-raises the
|
||||
#: recorded fault instead of touching the provider — so an outage is
|
||||
#: never amplified into one fetch per request.
|
||||
_JWKS_BACKOFF_SECONDS = 5.0
|
||||
|
||||
#: Seconds that must pass before ANOTHER forced (TTL-bypassing) refetch
|
||||
#: may run. An unmatched ``kid`` forces one refetch to pick up rotated
|
||||
#: keys; this interval bounds it, so a flood of unknown-``kid`` tokens
|
||||
#: cannot turn each request into a provider fetch either.
|
||||
_JWKS_KID_REFETCH_SECONDS = 5.0
|
||||
|
||||
#: Seconds before the real JWKS fetch gives up on the provider.
|
||||
_JWKS_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
#: An async callable that fetches the JWKS document from a URL. The
|
||||
#: real one (:func:`_fetch_jwks`) uses httpx; tests inject a stub so
|
||||
#: the suite stays hermetic (no network access).
|
||||
JwksFetcher = Callable[[str], Awaitable[dict[str, object]]]
|
||||
|
||||
|
||||
async def _fetch_jwks(url: str) -> dict[str, object]:
|
||||
"""Fetch the provider's JWKS document over HTTP (the real fetch).
|
||||
|
||||
Uses httpx with a short timeout; a non-2xx response or a body that
|
||||
is not a ``{"keys": [...]}`` object raises — the cache never stores
|
||||
a failed or malformed fetch.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=_JWKS_TIMEOUT_SECONDS) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
document = response.json()
|
||||
if not isinstance(document, dict) or not isinstance(
|
||||
document.get("keys"), list
|
||||
):
|
||||
raise ValueError("JWKS document must be an object with a keys list")
|
||||
return document
|
||||
|
||||
|
||||
class JwksCache:
|
||||
"""One app's cache of the OIDC provider's JWKS document.
|
||||
|
||||
The document is fetched through ``fetcher`` (the real one uses
|
||||
httpx against the configured JWKS URL) and held for ``ttl_seconds``
|
||||
— the dependency NEVER fetches per request. A single fetch is
|
||||
shared between concurrent requests via an :class:`asyncio.Lock`
|
||||
(double-checked around it), and a failed fetch is never cached, so
|
||||
the next request retries once the backoff window lapses rather than
|
||||
being locked out until the TTL does.
|
||||
|
||||
Two bounds keep the provider out of an amplification loop:
|
||||
|
||||
- ``backoff_seconds``: after a FAILED fetch, :meth:`get` fails fast
|
||||
for this long — re-raising the recorded fault WITHOUT touching
|
||||
the fetcher or even the lock — instead of retrying per request.
|
||||
- ``kid_refetch_seconds``: forced (TTL-bypassing) refetches — the
|
||||
unmatched-``kid`` path — may run at most this often; a forced
|
||||
refetch that is bounded out (or one inside the backoff window)
|
||||
is served from the cached document as-is.
|
||||
|
||||
A successful fetch always clears the backoff; the document itself
|
||||
is still trusted only for ``ttl_seconds`` — nothing is cached
|
||||
indefinitely.
|
||||
|
||||
One instance lives on ``app.state.jwks_cache`` (installed by
|
||||
:func:`api.app.create_app`); tests replace it with one built around
|
||||
a stub fetcher, keeping the suite hermetic.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
ttl_seconds: float = _JWKS_TTL_SECONDS,
|
||||
backoff_seconds: float = _JWKS_BACKOFF_SECONDS,
|
||||
kid_refetch_seconds: float = _JWKS_KID_REFETCH_SECONDS,
|
||||
fetcher: JwksFetcher | None = None,
|
||||
) -> None:
|
||||
self._url = url
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._backoff_seconds = backoff_seconds
|
||||
self._kid_refetch_seconds = kid_refetch_seconds
|
||||
self._fetcher: JwksFetcher = (
|
||||
fetcher if fetcher is not None else _fetch_jwks
|
||||
)
|
||||
self._document: dict[str, object] | None = None
|
||||
self._fetched_at: float | None = None
|
||||
self._failed_until: float | None = None
|
||||
self._last_error: BaseException | None = None
|
||||
self._forced_at: float | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self, *, force: bool = False) -> dict[str, object]:
|
||||
"""Return the JWKS document, refetching it if the TTL lapsed.
|
||||
|
||||
With ``force=True`` the TTL is bypassed so an unmatched ``kid``
|
||||
can pick up a rotated key without waiting it out; the forced
|
||||
refetch is itself bounded (see the class docstring): inside the
|
||||
backoff window or the ``kid_refetch_seconds`` interval the
|
||||
cached document is served as-is instead. A failed fetch is
|
||||
re-raised to the caller (a provider outage is a server fault,
|
||||
never a 401) and starts the backoff window, during which every
|
||||
caller — including forced ones with nothing cached — fails fast
|
||||
on the recorded fault.
|
||||
"""
|
||||
if not force:
|
||||
fresh = self._fresh_document()
|
||||
if fresh is not None:
|
||||
return fresh
|
||||
error = self._backoff_error()
|
||||
if error is not None:
|
||||
# Fail fast OUTSIDE the lock: a request inside the backoff
|
||||
# window must not queue behind an in-flight fetch.
|
||||
if force:
|
||||
document = self._document
|
||||
if document is not None:
|
||||
return document
|
||||
raise error
|
||||
async with self._lock:
|
||||
if not force:
|
||||
fresh = self._fresh_document()
|
||||
if fresh is not None:
|
||||
return fresh
|
||||
error = self._backoff_error()
|
||||
if error is not None:
|
||||
if force:
|
||||
document = self._document
|
||||
if document is not None:
|
||||
return document
|
||||
raise error
|
||||
if force and self._refetch_throttled():
|
||||
document = self._document
|
||||
if document is not None:
|
||||
return document
|
||||
return await self._fetch_locked(force=force)
|
||||
|
||||
def _fresh_document(self) -> dict[str, object] | None:
|
||||
"""The cached document if one exists and is inside its TTL."""
|
||||
document = self._document
|
||||
fetched_at = self._fetched_at
|
||||
if (
|
||||
document is not None
|
||||
and fetched_at is not None
|
||||
and (time.monotonic() - fetched_at) < self._ttl_seconds
|
||||
):
|
||||
return document
|
||||
return None
|
||||
|
||||
def _backoff_error(self) -> BaseException | None:
|
||||
"""The recorded fetch fault while the backoff window is open."""
|
||||
failed_until = self._failed_until
|
||||
last_error = self._last_error
|
||||
if (
|
||||
failed_until is not None
|
||||
and last_error is not None
|
||||
and time.monotonic() < failed_until
|
||||
):
|
||||
return last_error
|
||||
return None
|
||||
|
||||
def _refetch_throttled(self) -> bool:
|
||||
"""Whether a forced refetch ran too recently to run another."""
|
||||
forced_at = self._forced_at
|
||||
return forced_at is not None and (
|
||||
time.monotonic() - forced_at
|
||||
) < self._kid_refetch_seconds
|
||||
|
||||
async def _fetch_locked(self, *, force: bool) -> dict[str, object]:
|
||||
"""Run ONE fetch attempt (caller holds the lock) and record it.
|
||||
|
||||
A failed fetch starts the ``backoff_seconds`` window and is
|
||||
re-raised; a successful one stores the document, restarts the
|
||||
TTL and clears the backoff. A forced attempt stamps
|
||||
``_forced_at`` so the ``kid_refetch_seconds`` interval bounds
|
||||
how often forced refetches may run back-to-back.
|
||||
"""
|
||||
if force:
|
||||
self._forced_at = time.monotonic()
|
||||
try:
|
||||
document = await self._fetcher(self._url)
|
||||
except Exception as error:
|
||||
self._failed_until = time.monotonic() + self._backoff_seconds
|
||||
self._last_error = error
|
||||
raise
|
||||
self._document = document
|
||||
self._fetched_at = time.monotonic()
|
||||
self._failed_until = None
|
||||
self._last_error = None
|
||||
return document
|
||||
|
||||
|
||||
#: 403 detail for the panel service token on an admin route. Deliberately
|
||||
#: non-leaking: it names the credential TYPE, not its value or where it came
|
||||
#: from. 403 (not 401) because this is a REFUSED credential type, not an
|
||||
#: absent/invalid one — challenging re-authentication would suggest the same
|
||||
#: credential could work with a retry, which it never can here.
|
||||
_PANEL_TOKEN_REJECTION_DETAIL = "This credential type is not accepted for admin access"
|
||||
|
||||
#: 403 detail for a valid JWT that is not a member of the admin group.
|
||||
#: Distinct from the uniform 401 on purpose: the token AUTHENTICATED, but
|
||||
#: the caller is not AUTHORIZED, so the response may say so.
|
||||
_FORBIDDEN_DETAIL = "Admin access requires membership in the nestquest-admins group"
|
||||
|
||||
|
||||
def _reject() -> HTTPException:
|
||||
"""Build the single 401 the dependency raises for every failure."""
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail=_REJECTION_DETAIL,
|
||||
headers=_WWW_AUTHENTICATE,
|
||||
)
|
||||
|
||||
|
||||
def _matching_key(jwks: dict[str, object], kid: str) -> object | None:
|
||||
"""Return the RSA signing key named ``kid`` from the JWKS.
|
||||
|
||||
Only ``kty: RSA`` entries are considered (the pinned RS256 scheme
|
||||
is asymmetric RSA); anything else — a missing ``keys`` list, no
|
||||
entry with that ``kid`` — returns ``None`` rather than falling back
|
||||
to "some" key. The key is built through PyJWT's JWKS decoder, and
|
||||
an entry that MATCHES the ``kid`` but carries corrupt key material
|
||||
(a malformed ``n``/``e`` raises ``ValueError`` — ``binascii.Error``
|
||||
is its subclass — or ``TypeError`` instead of a ``PyJWTError``) is
|
||||
mapped to the one 401 like every other rejection: the caller must
|
||||
never see a half-built key.
|
||||
"""
|
||||
keys = jwks.get("keys")
|
||||
if not isinstance(keys, list):
|
||||
raise _reject()
|
||||
for entry in keys:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("kid") != kid or entry.get("kty") != "RSA":
|
||||
continue
|
||||
try:
|
||||
return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(entry))
|
||||
except (
|
||||
jwt.PyJWTError,
|
||||
binascii.Error,
|
||||
ValueError,
|
||||
TypeError,
|
||||
KeyError,
|
||||
):
|
||||
raise _reject() from None
|
||||
return None
|
||||
|
||||
|
||||
async def verify_admin_token(
|
||||
request: Request, token: str
|
||||
) -> dict[str, object]:
|
||||
"""Verify ONE admin bearer token and return its verified claims.
|
||||
|
||||
Reads the OIDC settings off ``app.state.config`` and the JWKS cache
|
||||
off ``app.state.jwks_cache`` (both installed by
|
||||
:func:`api.app.create_app`) so the check carries no module state.
|
||||
A missing config or cache, an EMPTY configured issuer/audience/JWKS
|
||||
URL, a non-RS256 ``alg`` header, an absent ``kid``, an unknown
|
||||
``kid``, and any PyJWT verification failure (bad signature,
|
||||
expired, wrong issuer, wrong audience, not-yet-valid, missing
|
||||
``exp``) raise the SAME 401 — the failure mode is never leaked.
|
||||
``exp`` is REQUIRED (a token that never expires is never accepted)
|
||||
and PyJWT enforces ``nbf`` when the token carries it.
|
||||
|
||||
The token's HEADER (``alg``, ``kid``) is parsed and enforced BEFORE
|
||||
the JWKS cache is consulted, so a structurally invalid token costs
|
||||
no fetch, no lock, no provider load. When the cached document has
|
||||
no entry for the ``kid``, ONE forced (TTL-bypassing, throttled)
|
||||
refetch is made and the match retried once — a rotated key is
|
||||
picked up without waiting out the TTL — before the 401.
|
||||
|
||||
A JWKS fetch failure propagates (see the module docstring): the
|
||||
provider being down is a server fault, not a bad credential.
|
||||
"""
|
||||
config = getattr(request.app.state, "config", None)
|
||||
if (
|
||||
config is None
|
||||
or not config.oidc_issuer
|
||||
or not config.oidc_audience
|
||||
or not config.oidc_jwks_url
|
||||
):
|
||||
raise _reject()
|
||||
cache = getattr(request.app.state, "jwks_cache", None)
|
||||
if cache is None:
|
||||
raise _reject()
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except jwt.PyJWTError:
|
||||
raise _reject() from None
|
||||
if header.get("alg") != _ALLOWED_ALGORITHM:
|
||||
raise _reject()
|
||||
kid = header.get("kid")
|
||||
if not isinstance(kid, str) or not kid:
|
||||
raise _reject()
|
||||
jwks = await cache.get()
|
||||
try:
|
||||
key = _matching_key(jwks, kid)
|
||||
if key is None:
|
||||
# The kid is not in the cached document: force ONE
|
||||
# TTL-bypassing refetch (bounded by the cache) and retry
|
||||
# the match once before rejecting.
|
||||
jwks = await cache.get(force=True)
|
||||
key = _matching_key(jwks, kid)
|
||||
if key is None:
|
||||
raise _reject()
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key=key,
|
||||
algorithms=[_ALLOWED_ALGORITHM],
|
||||
issuer=config.oidc_issuer,
|
||||
audience=config.oidc_audience,
|
||||
options={"require": ["exp"]},
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
raise _reject() from None
|
||||
return claims
|
||||
|
||||
|
||||
async def require_admin_jwt(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> dict[str, object]:
|
||||
"""Reject the request with 401 unless it carries a valid admin JWT.
|
||||
|
||||
Extracts the Bearer credential — a missing or malformed
|
||||
``Authorization`` header (wrong scheme, empty credential) raises
|
||||
the one 401 — and delegates the verification to
|
||||
:func:`verify_admin_token`, returning the verified claims for the
|
||||
route to use (the probe route surfaces the token's ``sub``).
|
||||
|
||||
The nestquest-admins GROUP check lives in :func:`require_admin`,
|
||||
which wraps this dependency; ``require_admin_jwt`` remains the plain
|
||||
JWT-validation contract — the 401 path stays exactly this uniform.
|
||||
"""
|
||||
if authorization is None:
|
||||
raise _reject()
|
||||
scheme, _, credential = authorization.partition(" ")
|
||||
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
|
||||
raise _reject()
|
||||
return await verify_admin_token(request, credential.strip())
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> dict[str, object]:
|
||||
"""The ONE admin-plane dependency: service-token refusal + group gate.
|
||||
|
||||
Order matters. FIRST the Bearer credential is compared against the
|
||||
configured panel service token (``app.state.config.panel_token``):
|
||||
a match is refused OUTRIGHT with 403 — BEFORE any JWT parsing — so
|
||||
the panel credential is never even treated as a JWT candidate, let
|
||||
alone a non-admin JWT. (403 rather than 401 because this is a
|
||||
refused credential TYPE, not an invalid one: challenging
|
||||
re-authentication would imply retrying could ever succeed; the
|
||||
detail names the type only, never the value.) A config with an
|
||||
EMPTY panel token never matches anything, so it cannot lock every
|
||||
Bearer request out.
|
||||
|
||||
Otherwise the credential goes through the existing JWT path
|
||||
(:func:`require_admin_jwt` → :func:`verify_admin_token`): a missing
|
||||
or malformed header and any invalid JWT still yield the SAME
|
||||
uniform 401 as before. A VALID JWT is then checked for
|
||||
authorization: its ``groups`` claim must be a list containing
|
||||
:data:`ADMIN_GROUP` — a missing, empty, or non-list ``groups`` or
|
||||
one without the group raises 403 (distinct from the 401 on
|
||||
purpose: the token authenticated, the caller did not authorize).
|
||||
|
||||
Returns the verified claims for the route.
|
||||
"""
|
||||
if authorization is not None:
|
||||
scheme, _, credential = authorization.partition(" ")
|
||||
if scheme.lower() == _BEARER_SCHEME and credential.strip():
|
||||
config = getattr(request.app.state, "config", None)
|
||||
if (
|
||||
config is not None
|
||||
and config.panel_token
|
||||
# compare_digest on bytes (as require_panel_token does)
|
||||
# so the refusal does not short-circuit on the first
|
||||
# mismatching byte — a constant-time comparison keeps a
|
||||
# timing side channel on the credential off the table.
|
||||
and secrets.compare_digest(
|
||||
credential.strip().encode("utf-8"),
|
||||
config.panel_token.encode("utf-8"),
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=_PANEL_TOKEN_REJECTION_DETAIL,
|
||||
)
|
||||
claims = await require_admin_jwt(request, authorization)
|
||||
groups = claims.get("groups")
|
||||
if not isinstance(groups, list) or ADMIN_GROUP not in groups:
|
||||
raise HTTPException(status_code=403, detail=_FORBIDDEN_DETAIL)
|
||||
return claims
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_GROUP",
|
||||
"JwksCache",
|
||||
"JwksFetcher",
|
||||
"require_admin",
|
||||
"require_admin_jwt",
|
||||
"verify_admin_token",
|
||||
]
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
"""Configuration for the NestQuest API service.
|
||||
|
||||
The API reads its configuration knobs from environment variables:
|
||||
|
||||
- ``NESTQUEST_DB_PATH`` — the SQLite database file. The API opens this
|
||||
file on startup (creating it if absent) and applies migrations.
|
||||
- ``NESTQUEST_PANEL_TOKEN`` — the static panel-plane service token
|
||||
(D-012): the credential ``/api/v1/panel/*`` routes require via
|
||||
:func:`api.dependencies.require_panel_token`. It is stored outside
|
||||
the repo and handed to the service through the environment.
|
||||
- ``NESTQUEST_OIDC_ISSUER`` — the Authentik OIDC issuer URL whose
|
||||
``iss`` claim admin tokens must carry (D-012 admin plane).
|
||||
- ``NESTQUEST_OIDC_AUDIENCE`` — the OIDC client id the admin tokens
|
||||
must carry as their ``aud`` claim (the API service's client id).
|
||||
- ``NESTQUEST_OIDC_JWKS_URL`` — the Authentik endpoint serving the
|
||||
signing keys (the JWKS document) the admin tokens' signatures are
|
||||
verified against. Cached per app (see :class:`api.auth.JwksCache`).
|
||||
|
||||
Unlike a dev convenience default, a missing or empty value is a
|
||||
configuration ERROR, not a silent fallback: a default database path
|
||||
would silently drop data at reboot, a default (or empty) service token
|
||||
would either authenticate everyone or lock the panel plane out for
|
||||
good, and a default (or empty) OIDC setting would silently leave the
|
||||
admin plane trusting an issuer that was never configured. All five
|
||||
variables are therefore REQUIRED — ``ApiConfig.from_env`` RAISES when
|
||||
any of them is unset or empty. This mirrors the integration's
|
||||
"explicit settings object" discipline (Feature 15's ``core.settings``):
|
||||
the API never reads HA config; callers pass the resolved values into
|
||||
the wiring.
|
||||
|
||||
Serving: the app is built with the factory ``api.app:create_app`` — run
|
||||
it with ``uvicorn --factory api.app:create_app`` so the factory reads
|
||||
the environment on each worker startup rather than at import time.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: Environment variable holding the SQLite file path. The API opens
|
||||
#: this file on startup (creating it if absent) and applies migrations.
|
||||
#: REQUIRED — unset or empty raises :class:`ConfigError` from
|
||||
#: :meth:`ApiConfig.from_env`.
|
||||
DB_PATH_ENV = "NESTQUEST_DB_PATH"
|
||||
|
||||
#: Environment variable holding the static panel-plane service token
|
||||
#: (D-012), checked by :func:`api.dependencies.require_panel_token`.
|
||||
#: REQUIRED — unset or empty raises :class:`ConfigError` from
|
||||
#: :meth:`ApiConfig.from_env`; there is no silent default, because an
|
||||
#: empty configured token would either authenticate every caller or
|
||||
#: lock the panel plane out permanently.
|
||||
PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN"
|
||||
|
||||
#: Environment variable holding the Authentik OIDC issuer URL (D-012
|
||||
#: admin plane): every admin JWT's ``iss`` claim must equal it. Checked
|
||||
#: by :func:`api.auth.require_admin_jwt`. REQUIRED — unset or empty
|
||||
#: raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; a default
|
||||
#: issuer would silently make the admin plane trust tokens from an
|
||||
#: issuer nobody configured.
|
||||
OIDC_ISSUER_ENV = "NESTQUEST_OIDC_ISSUER"
|
||||
|
||||
#: Environment variable holding the OIDC audience (the API service's
|
||||
#: client id at Authentik): every admin JWT's ``aud`` claim must carry
|
||||
#: it. Checked by :func:`api.auth.require_admin_jwt`. REQUIRED — unset
|
||||
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`.
|
||||
OIDC_AUDIENCE_ENV = "NESTQUEST_OIDC_AUDIENCE"
|
||||
|
||||
#: Environment variable holding the Authentik JWKS URL: the endpoint
|
||||
#: serving the signing keys admin JWT signatures are verified against.
|
||||
#: Fetched and cached by :class:`api.auth.JwksCache`. REQUIRED — unset
|
||||
#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`;
|
||||
#: without the provider's keys no token can be verified, so a silent
|
||||
#: default would only move the failure to request time.
|
||||
OIDC_JWKS_URL_ENV = "NESTQUEST_OIDC_JWKS_URL"
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when required API configuration is missing or invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiConfig:
|
||||
"""Resolved API configuration.
|
||||
|
||||
Every field is required (no defaults): ``from_env`` rejects an
|
||||
unset or empty value for each, and a caller building the dataclass
|
||||
directly must pass explicit values — the token check and the JWT
|
||||
checks themselves fail closed on empty configured values, but
|
||||
expecting a caller to discover that at request time is a wiring
|
||||
mistake, so every field is mandatory. Kept as a dataclass so later
|
||||
tasks (SSE, settings persistence) can extend it without touching
|
||||
the call sites that read ``db_path``.
|
||||
"""
|
||||
|
||||
db_path: str
|
||||
panel_token: str
|
||||
#: The Authentik OIDC issuer URL admin tokens' ``iss`` must equal.
|
||||
oidc_issuer: str
|
||||
#: The OIDC audience (the API service's client id) admin tokens'
|
||||
#: ``aud`` must carry.
|
||||
oidc_audience: str
|
||||
#: The Authentik JWKS URL the signing keys are fetched from.
|
||||
oidc_jwks_url: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig":
|
||||
"""Build configuration from the process environment.
|
||||
|
||||
``env`` defaults to ``os.environ``; tests pass an explicit dict
|
||||
so they are hermetic (no monkeypatch of the global environ).
|
||||
|
||||
Raises :class:`ConfigError` when ANY of the five variables is
|
||||
unset or empty/whitespace-only — there is no silent default:
|
||||
|
||||
- ``NESTQUEST_DB_PATH``: a default in the temp directory would
|
||||
silently lose data at reboot;
|
||||
- ``NESTQUEST_PANEL_TOKEN``: a default token is never invented —
|
||||
the panel plane is closed until a real one is configured;
|
||||
- the three OIDC variables: an unconfigured issuer/audience/JWKS
|
||||
URL must not silently leave the admin plane open (a default
|
||||
JWKS URL or issuer would point at a provider nobody set up).
|
||||
"""
|
||||
source = env if env is not None else os.environ
|
||||
|
||||
def require(env_var: str, reason: str) -> str:
|
||||
"""Return the stripped env value or raise ConfigError."""
|
||||
value = source.get(env_var, "").strip()
|
||||
if not value:
|
||||
raise ConfigError(f"{env_var} {reason}")
|
||||
return value
|
||||
|
||||
return cls(
|
||||
db_path=require(
|
||||
DB_PATH_ENV,
|
||||
"must be set to a writable SQLite file path (it is unset "
|
||||
"or empty — the API will not fall back to a temp directory "
|
||||
"and silently lose data)",
|
||||
),
|
||||
panel_token=require(
|
||||
PANEL_TOKEN_ENV,
|
||||
"must be set to the panel service token (it is unset or "
|
||||
"empty — the API will not start without one, and no "
|
||||
"default token is invented)",
|
||||
),
|
||||
oidc_issuer=require(
|
||||
OIDC_ISSUER_ENV,
|
||||
"must be set to the Authentik OIDC issuer URL (it is "
|
||||
"unset or empty — the admin plane will not trust tokens "
|
||||
"from an issuer nobody configured)",
|
||||
),
|
||||
oidc_audience=require(
|
||||
OIDC_AUDIENCE_ENV,
|
||||
"must be set to the OIDC audience the admin tokens carry "
|
||||
"(it is unset or empty — the admin plane will not accept "
|
||||
"tokens without a configured audience)",
|
||||
),
|
||||
oidc_jwks_url=require(
|
||||
OIDC_JWKS_URL_ENV,
|
||||
"must be set to the Authentik JWKS URL (it is unset or "
|
||||
"empty — admin token signatures cannot be verified "
|
||||
"without the provider's signing keys)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ApiConfig",
|
||||
"ConfigError",
|
||||
"DB_PATH_ENV",
|
||||
"OIDC_AUDIENCE_ENV",
|
||||
"OIDC_ISSUER_ENV",
|
||||
"OIDC_JWKS_URL_ENV",
|
||||
"PANEL_TOKEN_ENV",
|
||||
]
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Database wiring for the NestQuest API service.
|
||||
|
||||
The API owns the database (it is the writer of record per Feature 16's
|
||||
contract). This module owns one application-lifetime
|
||||
:class:`core.db.NestQuestDatabase`: it opens the SQLite file at the
|
||||
configured path on startup, applies migrations (creating the schema on
|
||||
a fresh file), and closes the connection on shutdown.
|
||||
|
||||
The executor callable passed into ``NestQuestDatabase`` mirrors the
|
||||
contract of ``hass.async_add_executor_job``: schedule a synchronous
|
||||
callable on a worker and await its result. HA's own version lives on
|
||||
the event loop; the API is not HA, so it uses
|
||||
:func:`asyncio.to_thread` for the same effect — sqlite3 calls run on a
|
||||
worker thread and only plain data returns to the event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from api.nestquest_core import core_db, core_migrations
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: The async SQLite wrapper. Re-exported so route handlers and later
|
||||
#: tasks reach it through the API package, not the bundled core.
|
||||
NestQuestDatabase = core_db.NestQuestDatabase
|
||||
|
||||
#: The migration runner. Re-exported for the same reason.
|
||||
apply_migrations = core_migrations.apply_migrations
|
||||
|
||||
|
||||
async def _executor(fn: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Run a synchronous callable on a worker thread and await its result.
|
||||
|
||||
Mirrors the contract of ``hass.async_add_executor_job`` so the
|
||||
bundled ``NestQuestDatabase`` (written against that contract) works
|
||||
unchanged in the API process: sqlite3 calls never block the event
|
||||
loop, and only plain Python data (row tuples,
|
||||
:class:`core.db.ExecutionResult`) crosses back.
|
||||
"""
|
||||
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||
|
||||
|
||||
class DatabaseState:
|
||||
"""Holds the live database wrapper and its resolved path.
|
||||
|
||||
A small container so the lifespan and the route handlers share one
|
||||
authoritative reference; FastAPI's lifespan stores one of these on
|
||||
``app.state.db`` and the health route reads it back.
|
||||
"""
|
||||
|
||||
__slots__ = ("database", "db_path")
|
||||
|
||||
def __init__(self, database: NestQuestDatabase, db_path: str) -> None:
|
||||
self.database = database
|
||||
self.db_path = db_path
|
||||
|
||||
async def open_and_migrate(self) -> None:
|
||||
"""Open the connection and apply migrations to the latest version."""
|
||||
LOGGER.info("Opening NestQuest database at %s", self.db_path)
|
||||
await self.database.open(self.db_path)
|
||||
version = await apply_migrations(self.database)
|
||||
LOGGER.info("NestQuest schema at version %d", version)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the connection; safe to call even if open failed."""
|
||||
await self.database.close()
|
||||
|
||||
|
||||
def make_database(db_path: str) -> DatabaseState:
|
||||
"""Construct a :class:`DatabaseState` for the given path (not yet open)."""
|
||||
return DatabaseState(NestQuestDatabase(_executor), db_path)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DatabaseState",
|
||||
"NestQuestDatabase",
|
||||
"apply_migrations",
|
||||
"make_database",
|
||||
]
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Reusable FastAPI dependencies for the NestQuest API routes.
|
||||
|
||||
The panel plane (``/api/v1/panel/*``) is authenticated by the static
|
||||
service token configured through ``NESTQUEST_PANEL_TOKEN`` (D-012): the
|
||||
HA integration presents it as a Bearer credential on every panel
|
||||
request. :func:`require_panel_token` is the ONE implementation of that
|
||||
check — every panel route (this task's snapshot route and each later
|
||||
panel route) declares it as a dependency rather than re-implementing
|
||||
token handling, so the credential, the failure behaviour, and the
|
||||
401 contract stay in exactly one place.
|
||||
|
||||
The dependency FAILS CLOSED: a missing ``Authorization`` header, a
|
||||
malformed one (wrong scheme or empty credential), a token that does not
|
||||
match the configured value, and an empty configured token all yield
|
||||
HTTP 401. The comparison is constant-time (:func:`secrets.compare_digest`)
|
||||
so the wall-clock difference between "wrong length" and "wrong value"
|
||||
cannot be used to probe the token byte by byte, and every rejection
|
||||
carries the same detail — nothing in the response distinguishes which
|
||||
part of the check failed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
#: The authorization scheme the panel plane expects. Matched
|
||||
#: case-insensitively, as RFC 9110 requires for scheme names.
|
||||
_BEARER_SCHEME = "bearer"
|
||||
|
||||
#: The 401 detail shared by every rejection — deliberately uniform so
|
||||
#: the response never reveals which part of the check failed.
|
||||
_REJECTION_DETAIL = "Missing or invalid panel service token"
|
||||
|
||||
#: Challenge header returned with a 401, per the Bearer auth scheme.
|
||||
_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"}
|
||||
|
||||
|
||||
def _reject() -> HTTPException:
|
||||
"""Build the single 401 the dependency raises for every failure."""
|
||||
return HTTPException(
|
||||
status_code=401,
|
||||
detail=_REJECTION_DETAIL,
|
||||
headers=_WWW_AUTHENTICATE,
|
||||
)
|
||||
|
||||
|
||||
async def require_panel_token(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> None:
|
||||
"""Reject the request with 401 unless it carries the panel token.
|
||||
|
||||
Reads the configured token off ``app.state.config`` (set by
|
||||
:func:`api.app.create_app`) so the dependency stays reusable across
|
||||
routes and carries no module state of its own. A missing or
|
||||
malformed ``Authorization`` header, a non-matching credential, and
|
||||
an EMPTY configured token (a wiring mistake) all raise the same
|
||||
``HTTPException(401)`` — the check is closed even when the service
|
||||
is misconfigured.
|
||||
|
||||
Returns ``None`` on success; FastAPI discards a ``None`` return,
|
||||
the route handler simply proceeds.
|
||||
"""
|
||||
config = getattr(request.app.state, "config", None)
|
||||
expected = config.panel_token if config is not None else ""
|
||||
if not expected or authorization is None:
|
||||
raise _reject()
|
||||
scheme, _, credential = authorization.partition(" ")
|
||||
if scheme.lower() != _BEARER_SCHEME or not credential.strip():
|
||||
raise _reject()
|
||||
# compare_digest on bytes so a non-ASCII token raises nothing and a
|
||||
# timing side channel on the str path is not available.
|
||||
if not secrets.compare_digest(
|
||||
credential.strip().encode("utf-8"), expected.encode("utf-8")
|
||||
):
|
||||
raise _reject()
|
||||
|
||||
|
||||
__all__ = ["require_panel_token"]
|
||||
@@ -1,78 +0,0 @@
|
||||
"""The ONE in-process transition-event publisher for the API service.
|
||||
|
||||
Route handlers call :meth:`TransitionPublisher.publish` synchronously
|
||||
(no ``await``, callable from any async handler); SSE subscribers stream
|
||||
those transitions through an async generator. The scope of "ONE" is
|
||||
deliberate: a single :class:`TransitionPublisher` instance is created
|
||||
in the app lifespan and stored on ``app.state.publisher`` — per-app,
|
||||
so tests build their own hermetic app without module-level state, and
|
||||
both the panel complete route and the SSE endpoint resolve the SAME
|
||||
instance off ``request.app.state`` (exactly one publisher exists per
|
||||
running app; nothing else may create one).
|
||||
|
||||
Design (small and dependency-free, asyncio only):
|
||||
|
||||
- Each subscriber gets its own ``asyncio.Queue(maxsize=64)``.
|
||||
- ``publish`` is synchronous and non-blocking: it puts the event on
|
||||
every live queue with ``put_nowait``. A slow subscriber that lets
|
||||
its queue fill gets the event DROPPED (oldest overflow) — never a
|
||||
blocked publisher, never unbounded growth.
|
||||
- A disconnected subscriber's queue is removed in the generator's
|
||||
``finally`` block, so teardown is clean and a closed consumer cannot
|
||||
leak its queue.
|
||||
|
||||
Fan-out is fire-and-forget by design: the SSE plane mirrors transition
|
||||
events for the panel UI; a dropped event costs the panel one UI
|
||||
refresh, and the documented recovery path is the next snapshot poll.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
#: Per-subscriber queue capacity. Bounded so a stalled consumer can
|
||||
#: never make the publisher or another subscriber wait; beyond this
|
||||
#: the slow subscriber drops the event (see module docstring).
|
||||
_QUEUE_MAXSIZE = 64
|
||||
|
||||
__all__ = ["TransitionPublisher"]
|
||||
|
||||
|
||||
class TransitionPublisher:
|
||||
"""A single in-process fan-out point for transition events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create the publisher with no subscribers."""
|
||||
self._subscribers: set[asyncio.Queue] = set()
|
||||
|
||||
def publish(self, event_type: str, payload: dict) -> None:
|
||||
"""Fan one transition event out to every current subscriber.
|
||||
|
||||
Synchronous and non-blocking: each subscriber's bounded queue
|
||||
is filled with ``put_nowait``, and a FULL queue silently drops
|
||||
the event for that (slow) subscriber rather than blocking the
|
||||
route handler or growing without bound.
|
||||
"""
|
||||
for queue in tuple(self._subscribers):
|
||||
try:
|
||||
queue.put_nowait((event_type, payload))
|
||||
except asyncio.QueueFull:
|
||||
# The subscriber is not keeping up; drop the event for
|
||||
# it and move on (the panel recovers by polling).
|
||||
pass
|
||||
|
||||
async def subscribe(self) -> AsyncIterator[tuple[str, dict]]:
|
||||
"""Yield ``(event_type, payload)`` tuples until the consumer breaks.
|
||||
|
||||
Registers ONE bounded queue for the duration of the iteration
|
||||
and removes it in the ``finally`` block, so a client disconnect
|
||||
(or any break out of the loop) tears the subscription down
|
||||
cleanly with no leaked queue.
|
||||
"""
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
|
||||
self._subscribers.add(queue)
|
||||
try:
|
||||
while True:
|
||||
yield await queue.get()
|
||||
finally:
|
||||
self._subscribers.discard(queue)
|
||||
@@ -1,228 +0,0 @@
|
||||
"""Transitional coupling: import ``core`` without importing Home Assistant.
|
||||
|
||||
The NestQuest domain ``core`` package (Feature 15) is bundled INSIDE the
|
||||
Home Assistant integration at ``custom_components/nestquest/core/``.
|
||||
Importing it the obvious way — ``import custom_components.nestquest.core``
|
||||
— would execute ``custom_components/nestquest/__init__.py``, which
|
||||
imports ``homeassistant``. Home Assistant is NOT installed in the API
|
||||
service's runtime, so that import raises ``ModuleNotFoundError`` and the
|
||||
API cannot start.
|
||||
|
||||
``core`` itself is a clean, Home-Assistant-free package: its
|
||||
``__init__.py`` is a bare docstring and every internal import is
|
||||
relative (``from .db import ...``). It can therefore be imported by
|
||||
file location as a standalone package.
|
||||
|
||||
This module loads ``core`` with :func:`importlib.util.spec_from_file_location`
|
||||
— registering it under the distinct top-level name ``nestquest_core``
|
||||
with ``submodule_search_locations`` set to the bundled ``core``
|
||||
directory — so its relative imports resolve against that directory
|
||||
without touching ``sys.path``. No generic top-level name (``db``,
|
||||
``const``, ``schema``, ...) is published, so nothing here can silently
|
||||
shadow a future dependency, and the integration's HA-coupled
|
||||
``__init__.py`` is never executed: ``custom_components.nestquest`` is
|
||||
never imported.
|
||||
|
||||
Usage::
|
||||
|
||||
from api.nestquest_core import core_db, core_migrations
|
||||
# core_db.NestQuestDatabase, core_migrations.apply_migrations, ...
|
||||
|
||||
This is a TRANSITIONAL coupling. Feature 18 (task 430f7f98) converts
|
||||
the integration into a thin API client and moves ``core`` out of the
|
||||
integration bundle; once that lands, this bootstrap helper goes away and
|
||||
the API imports ``core`` as an ordinary installed dependency.
|
||||
|
||||
NOTE on the pytest process: the suite's ``conftest.py`` stubs the
|
||||
parent integration package and imports ``custom_components.nestquest.core.*``
|
||||
BEFORE any API test runs, so two copies of the core package coexist in
|
||||
the pytest process — the one registered here as ``nestquest_core.*``
|
||||
(loaded by file location) and the conftest-stubbed
|
||||
``custom_components.nestquest.core.*``. Later tasks must NOT compare
|
||||
class or exception identity across these two copies: a
|
||||
``nestquest_core.db.NestQuestDatabase`` is a DIFFERENT class object from
|
||||
``custom_components.nestquest.core.db.NestQuestDatabase`` even though
|
||||
they share source. Operate on a single copy within any one code path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
#: The repository root (this file is ``<root>/api/nestquest_core.py``).
|
||||
_REPO_ROOT: Path = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: The bundled ``core`` package directory, inside the HA integration.
|
||||
_CORE_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest" / "core"
|
||||
|
||||
#: The ``core`` package's ``__init__.py`` — a bare docstring, so loading
|
||||
#: it by file location executes no imports of its own.
|
||||
_CORE_INIT: Path = _CORE_DIR / "__init__.py"
|
||||
|
||||
#: The distinct top-level name under which the bundled ``core`` package
|
||||
#: is registered in ``sys.modules``. A distinctive name (rather than the
|
||||
#: generic ``core``) avoids publishing shadow-able top-level names and
|
||||
#: keeps the bundled copy separate from the conftest-stubbed
|
||||
#: ``custom_components.nestquest.core`` present in the pytest process.
|
||||
_CORE_MODULE_NAME = "nestquest_core"
|
||||
|
||||
|
||||
def _load_core() -> ModuleType:
|
||||
"""Load the bundled ``core`` package by file location and return it.
|
||||
|
||||
Registers the package under :data:`_CORE_MODULE_NAME` in
|
||||
``sys.modules`` with ``submodule_search_locations`` pointing at the
|
||||
bundled directory, so its relative imports (``from .db import ...``)
|
||||
resolve to ``nestquest_core.<submodule>`` against that directory
|
||||
without mutating ``sys.path``. Idempotent: a second call returns
|
||||
the already-registered module.
|
||||
"""
|
||||
existing = sys.modules.get(_CORE_MODULE_NAME)
|
||||
if existing is not None:
|
||||
return existing
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
_CORE_MODULE_NAME,
|
||||
_CORE_INIT,
|
||||
submodule_search_locations=[str(_CORE_DIR)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"could not load core from {_CORE_INIT}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[_CORE_MODULE_NAME] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
#: The bundled core package, loaded by file location and registered as
|
||||
#: ``nestquest_core`` in ``sys.modules``. Held here so callers import the
|
||||
#: submodules through it (``nestquest_core.db``, ``nestquest_core.migrations``)
|
||||
#: the same relative way the integration does.
|
||||
core: ModuleType = _load_core()
|
||||
|
||||
#: Re-exported for convenience; route handlers import these names from
|
||||
#: here rather than reaching into the core package directly.
|
||||
core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db")
|
||||
core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations")
|
||||
|
||||
#: The panel snapshot builder and its payload shaper
|
||||
#: (``core.snapshot.build_snapshot`` / ``instance_payload``) — the pure
|
||||
#: assembly the panel routes call.
|
||||
core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot")
|
||||
|
||||
#: The completion service (``core.completion.complete_instance`` /
|
||||
#: ``uncomplete_instance``) the panel's write routes call.
|
||||
core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion")
|
||||
|
||||
#: The read-only history layer (``core.history.query_history``,
|
||||
#: ``export_history_csv``, ``rows_to_csv``) the admin plane's history
|
||||
#: query and CSV export routes call.
|
||||
core_history = importlib.import_module(f"{_CORE_MODULE_NAME}.history")
|
||||
|
||||
#: The transition-event builders (``core.events.build_quest_completed_event``
|
||||
#: et al.) the panel's write routes build their SSE events with.
|
||||
core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events")
|
||||
|
||||
#: The explicit settings object (``core.settings.NestQuestSettings``)
|
||||
#: the snapshot builder's ``settings`` parameter takes.
|
||||
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
|
||||
|
||||
#: The durable settings store (``core.settings_store.load_settings`` /
|
||||
#: ``update_settings``) the admin plane's settings routes read and
|
||||
#: write — the API's OWN settings source: a validated JSON document in
|
||||
#: the existing ``nestquest_meta_state`` table, resolved through the
|
||||
#: same core settings validators (the integration's HA config-entry
|
||||
#: options remain a temporary separate source until Feature 18 wires
|
||||
#: the client to the API).
|
||||
core_settings_store = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.settings_store"
|
||||
)
|
||||
|
||||
#: The children business layer (``core.children.create_child`` et al.)
|
||||
#: the admin plane's children routes call.
|
||||
core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children")
|
||||
|
||||
#: The quest-definition business layer (``core.quest_definitions`` —
|
||||
#: ``create_quest_definition``, ``edit_quest_definition``,
|
||||
#: ``set_quest_definition_active``) the admin plane's quest-definition
|
||||
#: routes call.
|
||||
core_quest_definitions = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.quest_definitions"
|
||||
)
|
||||
|
||||
#: The recurrence model (``core.recurrence.ScheduleRule`` and
|
||||
#: ``RuleValidationError``) the quest-definition routes build request
|
||||
#: rules with — the model validates at construction, the routes never
|
||||
#: re-implement rule validation.
|
||||
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
|
||||
|
||||
#: The presence model (``core.presence.PresenceEngine``,
|
||||
#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's
|
||||
#: presence routes serialize and the tests resolve presence with.
|
||||
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
|
||||
|
||||
#: The presence-write business layer
|
||||
#: (``core.presence_management.set_presence_schedule`` et al.) the
|
||||
#: admin plane's presence routes call — the ONE implementation the HA
|
||||
#: services share.
|
||||
core_presence_management = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.presence_management"
|
||||
)
|
||||
|
||||
#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests
|
||||
#: read stored schedules and overrides back through.
|
||||
core_dao_presence = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.dao_presence"
|
||||
)
|
||||
|
||||
#: The materialization walk (``core.materialize.materialize``,
|
||||
#: ``regenerate_for_definition``, ``regenerate_for_child``) the admin
|
||||
#: plane's regenerate route calls — the same walk the integration's
|
||||
#: config/presence changes trigger.
|
||||
core_materialize = importlib.import_module(f"{_CORE_MODULE_NAME}.materialize")
|
||||
|
||||
#: The core constants module (``core.const.DEFAULT_HORIZON_DAYS``) the
|
||||
#: regenerate route sizes the household rolling horizon with.
|
||||
core_const = importlib.import_module(f"{_CORE_MODULE_NAME}.const")
|
||||
|
||||
#: The typed child DAO (``core.dao_children.ChildrenDao.get``) the
|
||||
#: regenerate route reads a child's existence with before delegating.
|
||||
core_dao_children = importlib.import_module(
|
||||
f"{_CORE_MODULE_NAME}.dao_children"
|
||||
)
|
||||
|
||||
#: The typed definition DAO (``core.dao_rules.QuestDefinitionsDao.get``)
|
||||
#: the regenerate route reads a definition's existence with before
|
||||
#: delegating.
|
||||
core_dao_rules = importlib.import_module(f"{_CORE_MODULE_NAME}.dao_rules")
|
||||
|
||||
#: The HA-free missed sweep (``core.sweep.run_missed_sweep`` +
|
||||
#: ``SWEEP_WATERMARK_KEY``) the admin plane's trigger route calls and
|
||||
#: the daily scheduler runs — the ONE sweep policy the integration's
|
||||
#: bus shim shares.
|
||||
core_sweep = importlib.import_module(f"{_CORE_MODULE_NAME}.sweep")
|
||||
|
||||
__all__ = [
|
||||
"core",
|
||||
"core_children",
|
||||
"core_completion",
|
||||
"core_const",
|
||||
"core_dao_children",
|
||||
"core_dao_presence",
|
||||
"core_dao_rules",
|
||||
"core_db",
|
||||
"core_events",
|
||||
"core_history",
|
||||
"core_materialize",
|
||||
"core_migrations",
|
||||
"core_presence",
|
||||
"core_presence_management",
|
||||
"core_quest_definitions",
|
||||
"core_recurrence",
|
||||
"core_snapshot",
|
||||
"core_settings",
|
||||
"core_settings_store",
|
||||
"core_sweep",
|
||||
]
|
||||
-1888
File diff suppressed because it is too large
Load Diff
@@ -1,373 +0,0 @@
|
||||
"""Panel-plane routes for the NestQuest API service.
|
||||
|
||||
The panel plane (``/api/v1/panel/*``, D-012) serves the wall panel's
|
||||
reads. Every route on :data:`router` requires the panel service token
|
||||
through the ONE reusable dependency :func:`api.dependencies.require_panel_token`
|
||||
(declared once at the router level, so each later panel route added to
|
||||
this router inherits the check).
|
||||
|
||||
The snapshot route is a thin adapter over the bundled core: it performs
|
||||
NO business logic. It reads the live database off ``app.state.db``,
|
||||
resolves ONE timezone-aware ``now`` (the API host's local time — the
|
||||
API is the household's "local" host now that the domain no longer lives
|
||||
inside Home Assistant), calls the pure
|
||||
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
|
||||
instances through :func:`nestquest_core.snapshot.instance_payload` with
|
||||
``include_missed=False`` (D-009: missed instances are OMITTED from the
|
||||
panel payload), and returns the documented JSON shape:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"today_iso": "2026-09-22",
|
||||
"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": null, "window": "morning",
|
||||
"due_time": "10:00", "state": "open", "overdue": true,
|
||||
"completed_at": null, "on_time": null}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
``children`` follows the household's sort order (the core builder lists
|
||||
active children ``ORDER BY sort_order, id``); ``state`` is the
|
||||
documented panel spelling ``open`` | ``completed`` (§1 of
|
||||
design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day
|
||||
in the first scheduled child's custody cycle (0 when no child has one).
|
||||
The same :class:`PanelSnapshotResponse` Pydantic model is the route's
|
||||
``response_model``, so the OpenAPI document carries the shape too.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
import json
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from api.database import DatabaseState
|
||||
from api.dependencies import require_panel_token
|
||||
from api.nestquest_core import (
|
||||
core_completion,
|
||||
core_events,
|
||||
core_settings,
|
||||
core_snapshot,
|
||||
)
|
||||
from api import transitions as api_transitions
|
||||
|
||||
#: The instance DAO, imported on the app's core copy for the
|
||||
#: child-mismatch guard on the complete route.
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
|
||||
#: All panel-plane routes share this router; the service-token check is
|
||||
#: attached HERE so every panel route (current and later) requires it.
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/panel",
|
||||
dependencies=[Depends(require_panel_token)],
|
||||
)
|
||||
|
||||
|
||||
class PanelInstancePayload(BaseModel):
|
||||
"""One instance in the documented panel payload shape (§1).
|
||||
|
||||
Mirrors :func:`nestquest_core.snapshot.instance_payload`:
|
||||
``state`` is ``open`` | ``completed`` (missed instances never reach
|
||||
the panel payload), ``on_time`` passes the completion event's flag
|
||||
through verbatim (``None`` for an open instance), 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 PanelChildPayload(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
|
||||
#: (the away plate renders ``Returns <weekday>, <Mon D>`` from it);
|
||||
#: ``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[PanelInstancePayload]
|
||||
|
||||
|
||||
class PanelSnapshotResponse(BaseModel):
|
||||
"""The whole household snapshot the panel renders from.
|
||||
|
||||
``today_iso`` anchors the payload's day; ``cycle_day`` is today's
|
||||
1-based day in the first scheduled child's custody cycle (0 when no
|
||||
child has a schedule); ``children`` is in the household's sort
|
||||
order.
|
||||
"""
|
||||
|
||||
today_iso: str
|
||||
cycle_day: int
|
||||
children: list[PanelChildPayload]
|
||||
|
||||
|
||||
class PanelCompleteRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/panel/instances/{id}/complete``.
|
||||
|
||||
Matches the panel's ``nestquest.complete_quest`` payload (§2 of
|
||||
design/ENTITIES-AND-SERVICES.md): ``actor_child_id`` is the tapped
|
||||
profile. A positive integer is required — the core completion
|
||||
layer rejects non-int (and bool) ids anyway, but rejecting them
|
||||
here with 422 keeps the contract visible in the OpenAPI document
|
||||
and fails before any database work.
|
||||
"""
|
||||
|
||||
actor_child_id: int
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"description": (
|
||||
"The tapped child profile the completion is "
|
||||
"attributed to (decision 9)."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@field_validator("actor_child_id")
|
||||
@classmethod
|
||||
def _positive(cls, value: int) -> int:
|
||||
"""Reject zero and negative ids with a validation error."""
|
||||
if value < 1:
|
||||
raise ValueError("actor_child_id must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def _local_now() -> datetime.datetime:
|
||||
"""Return the API host's local time as ONE timezone-aware clock read.
|
||||
|
||||
``now().astimezone()`` attaches the host's local timezone to a
|
||||
single clock read, so the calendar date and wall-clock time the
|
||||
builder derives can never disagree. Kept as a module function (not
|
||||
inlined) so tests pin the instant the same way the coordinator's
|
||||
``_local_now`` is pinned.
|
||||
"""
|
||||
return datetime.datetime.now().astimezone()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/snapshot",
|
||||
summary="Today's household snapshot",
|
||||
response_model=PanelSnapshotResponse,
|
||||
)
|
||||
async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
|
||||
"""Return today's full household snapshot for the panel.
|
||||
|
||||
Requires the panel service token (checked by the router's shared
|
||||
:func:`~api.dependencies.require_panel_token` dependency). Builds
|
||||
the snapshot through the pure core builder against the live
|
||||
database and shapes each child's instances with
|
||||
``instance_payload(include_missed=False)`` — missed instances are
|
||||
OMITTED from the panel payload (D-009) — then returns the shape
|
||||
documented in this module's docstring and
|
||||
:class:`PanelSnapshotResponse`.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
snapshot = await core_snapshot.build_snapshot(
|
||||
state.database, core_settings.NestQuestSettings(), _local_now()
|
||||
)
|
||||
return PanelSnapshotResponse(
|
||||
today_iso=snapshot.today_iso,
|
||||
cycle_day=snapshot.cycle_day,
|
||||
children=[
|
||||
PanelChildPayload(
|
||||
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=[
|
||||
PanelInstancePayload.model_validate(instance)
|
||||
for instance in core_snapshot.instance_payload(
|
||||
child.instances, include_missed=False
|
||||
)
|
||||
],
|
||||
)
|
||||
for child in snapshot.children
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/instances/{instance_id}/complete",
|
||||
summary="Complete one quest instance",
|
||||
status_code=200,
|
||||
)
|
||||
async def panel_complete_instance(
|
||||
instance_id: int, body: PanelCompleteRequest, request: Request
|
||||
) -> dict[str, str]:
|
||||
"""Complete one quest instance as the tapped child profile.
|
||||
|
||||
Requires the panel service token (checked by the router's shared
|
||||
:func:`~api.dependencies.require_panel_token` dependency — the ONE
|
||||
token check; this handler performs NO auth of its own).
|
||||
|
||||
Thin adapter only: resolves the live database off ``app.state.db``,
|
||||
reads ONE timezone-aware clock (:func:`_local_now`), and delegates
|
||||
to :func:`nestquest_core.completion.complete_instance` with
|
||||
``actor_source="panel"`` and the tapped ``actor_child_id`` — the
|
||||
append/idempotence logic, the lock, and the actor-shape validation
|
||||
all live in the core layer, never here.
|
||||
|
||||
Response and idempotence: ``complete_instance`` returns a
|
||||
:class:`~nestquest_core.completion.CompletionResult` whose
|
||||
``appended`` flag reports whether THIS call wrote an event; a
|
||||
repeat call for an already-completed instance is a no-op inside
|
||||
the core layer (no duplicate event) and this route still answers
|
||||
``{"status": "done"}`` either way, so a retried panel tap succeeds.
|
||||
|
||||
Error mapping (deliberately narrow): the ONLY ``ValueError`` mapped
|
||||
to 404 is the unknown-``instance_id`` one, identified by the
|
||||
``instance_id:`` prefix ``complete_instance`` names the failing
|
||||
field with. Every other ``ValueError`` (an invalid actor shape,
|
||||
which the body model and the panel actor contract already make
|
||||
unreachable) re-raises to the framework's 500 — nothing else is
|
||||
turned into a 404 or a 500 of our own making.
|
||||
|
||||
Child mismatch: ``complete_instance`` verifies the tapped
|
||||
``actor_child_id`` exists but does NOT check it is the child the
|
||||
instance belongs to. The route performs that check here (one
|
||||
``QuestInstancesDao.get_by_id`` read) and answers 404 when the
|
||||
instance's ``child_id`` differs — completing another child's quest
|
||||
from the wrong profile must not succeed, and 404 (not 403) keeps
|
||||
the instance's existence from leaking to a wrong-profile tap.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
database = state.database
|
||||
|
||||
# Child-mismatch guard (see docstring): the instance must belong to
|
||||
# the tapped profile before the completion layer is entered.
|
||||
instance = await _dao_instances.QuestInstancesDao(database).get_by_id(
|
||||
instance_id
|
||||
)
|
||||
if instance is not None and instance.child_id != body.actor_child_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Quest instance not found for this child",
|
||||
)
|
||||
|
||||
now = _local_now()
|
||||
try:
|
||||
result = await core_completion.complete_instance(
|
||||
database,
|
||||
instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=body.actor_child_id,
|
||||
now=now,
|
||||
)
|
||||
except ValueError as error:
|
||||
message = str(error)
|
||||
if message.startswith("instance_id:"):
|
||||
# complete_instance names the field in its ValueError:
|
||||
# "instance_id: quest instance N does not exist".
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Quest instance not found",
|
||||
) from error
|
||||
raise
|
||||
# The derived state is "done" in both the appended and no-op cases;
|
||||
# ``result.appended`` distinguishes them for callers that care —
|
||||
# the panel route returns the same success either way.
|
||||
if result.appended:
|
||||
# An ACTUAL transition: publish the SSE transition event. The
|
||||
# payload is built by the core builder (never hand-built here)
|
||||
# and fan-out is fire-and-forget (see api/events.py).
|
||||
event_type, payload = await core_events.build_quest_completed_event(
|
||||
database, instance_id, was_on_time=result.was_on_time
|
||||
)
|
||||
request.app.state.publisher.publish(event_type, payload)
|
||||
# THEN evaluate the day-complete rule off the completed event's
|
||||
# payload (the core builder decides when the child's whole day
|
||||
# is cleared — a zero-quest day and a non-today instance never
|
||||
# fire it). ``today`` comes from the route's single clock read
|
||||
# so the calendar date can never disagree with the completion's.
|
||||
await api_transitions.publish_child_day_complete(
|
||||
database,
|
||||
payload,
|
||||
now.date(),
|
||||
request.app.state.publisher,
|
||||
)
|
||||
return {"status": str(result)}
|
||||
|
||||
|
||||
async def _sse_stream(request: Request) -> AsyncIterator[str]:
|
||||
"""Yield the SSE frames for every published transition event.
|
||||
|
||||
One subscriber of the app's single publisher (resolved off
|
||||
``app.state.publisher``); each event becomes one
|
||||
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
|
||||
publisher's subscription (on client disconnect) tears the
|
||||
subscription down cleanly — no queue is leaked.
|
||||
"""
|
||||
publisher = request.app.state.publisher
|
||||
async for event_type, payload in publisher.subscribe():
|
||||
yield (
|
||||
f"event: {event_type}\n"
|
||||
f"data: {json.dumps(payload)}\n\n"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/events",
|
||||
summary="Stream panel transition events (SSE)",
|
||||
)
|
||||
async def panel_events(request: Request) -> StreamingResponse:
|
||||
"""Stream the app's transition events to the panel (Server-Sent
|
||||
Events).
|
||||
|
||||
Requires the panel service token (checked by the router's shared
|
||||
:func:`~api.dependencies.require_panel_token` dependency — the ONE
|
||||
token check; this handler performs NO auth of its own).
|
||||
|
||||
Returns a ``text/event-stream`` response whose body is produced by
|
||||
:func:`_sse_stream` off the app's single in-process publisher
|
||||
(``app.state.publisher``, installed in the lifespan). The stream
|
||||
ends when the client disconnects, at which point the subscription
|
||||
is torn down (its queue removed) with nothing leaked.
|
||||
"""
|
||||
return StreamingResponse(
|
||||
_sse_stream(request),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Daily missed-sweep background scheduler for the API service.
|
||||
|
||||
The API service has no Home Assistant, so nobody re-fires the
|
||||
integration's day-rollover listener for it: this module owns the API
|
||||
plane's own background job. One :class:`MissedSweepScheduler` per app
|
||||
(installed on ``app.state`` by the lifespan) sleeps until the
|
||||
configured ``day_rollover_time`` — read fresh from the settings store
|
||||
at the top of EVERY cycle, so an admin's settings change applies on
|
||||
the next scheduling decision without a restart — then runs the
|
||||
HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the
|
||||
API host's local date and publishes each returned
|
||||
``nestquest_quest_missed`` event on the app's ONE transition publisher
|
||||
(the same SSE stream every route publishes through).
|
||||
|
||||
All sweep policy lives in the core (the watermark, the
|
||||
``[watermark, today)`` window, the no-completion-event rule): this
|
||||
scheduler only decides WHEN. A same-night rerun — e.g. two workers,
|
||||
or a settings change moving the rollover past itself — fires nothing:
|
||||
the core's watermark makes the run an empty no-op.
|
||||
|
||||
Testability: ``clock`` (an aware-now callable) and ``sleep`` (an
|
||||
awaitable delay) are injectable, so tests pin the time, fast-forward
|
||||
through the wait, and observe the computed delays without waiting a
|
||||
real day. Cancellation: :meth:`stop` cancels the loop task and waits
|
||||
for it, so the lifespan's shutdown is clean and a mid-run sweep is
|
||||
interrupted before the connection closes. A failed iteration (a
|
||||
database hiccup, a rejected setting) is logged and retried after a
|
||||
fixed backoff rather than silently killing the job.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from api.nestquest_core import core_settings_store, core_sweep
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: Backoff after a failed scheduling cycle (settings read or sweep
|
||||
#: error), so a persistent failure retries every minute instead of
|
||||
#: spinning or dying.
|
||||
_RETRY_BACKOFF_SECONDS = 60.0
|
||||
|
||||
|
||||
def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float:
|
||||
"""Seconds from ``now`` until the next occurrence of ``target``.
|
||||
|
||||
``target`` is a plain ``HH:MM`` time of day: the next wall-clock
|
||||
occurrence strictly AFTER ``now`` (an equal time schedules for
|
||||
tomorrow, so the just-run sweep is never instantly re-run).
|
||||
"""
|
||||
scheduled = now.replace(
|
||||
hour=target.hour, minute=target.minute, second=0, microsecond=0
|
||||
)
|
||||
if scheduled <= now:
|
||||
scheduled += datetime.timedelta(days=1)
|
||||
return (scheduled - now).total_seconds()
|
||||
|
||||
|
||||
class MissedSweepScheduler:
|
||||
"""Run the missed sweep once per day at ``day_rollover_time``."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
database: object,
|
||||
*,
|
||||
publisher: object | None = None,
|
||||
clock: object | None = None,
|
||||
sleep: object | None = None,
|
||||
) -> None:
|
||||
"""Create the scheduler (not yet running).
|
||||
|
||||
``database`` is the app's live :class:`~api.database.NestQuestDatabase`.
|
||||
``publisher`` is the app's ONE
|
||||
:class:`~api.events.TransitionPublisher` (publishing is skipped
|
||||
when ``None``). ``clock`` defaults to the API host's local
|
||||
aware now (``datetime.datetime.now().astimezone()``) — the same
|
||||
single-clock discipline the routes' ``_local_now`` uses;
|
||||
``sleep`` defaults to :func:`asyncio.sleep`.
|
||||
"""
|
||||
self._database = database
|
||||
self._publisher = publisher
|
||||
self._clock = clock if clock is not None else (
|
||||
lambda: datetime.datetime.now().astimezone()
|
||||
)
|
||||
self._sleep = sleep if sleep is not None else asyncio.sleep
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the background loop (idempotent: a running one stays)."""
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(
|
||||
self._run_forever(), name="nestquest-missed-sweep"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the loop and wait for it to finish (safe if never started)."""
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""Whether the background loop task is alive."""
|
||||
return self._task is not None and not self._task.done()
|
||||
|
||||
async def _run_forever(self) -> None:
|
||||
"""Schedule → sweep → repeat, forever (until cancelled)."""
|
||||
while True:
|
||||
try:
|
||||
settings = await core_settings_store.load_settings(
|
||||
self._database
|
||||
)
|
||||
rollover = datetime.time.fromisoformat(
|
||||
settings.day_rollover_time
|
||||
)
|
||||
except Exception:
|
||||
# Not silent: the failure is logged and retried after
|
||||
# the backoff, so one bad settings document or a
|
||||
# transient database error never kills the scheduler.
|
||||
LOGGER.exception(
|
||||
"NestQuest missed-sweep scheduler failed to resolve "
|
||||
"the day rollover time; retrying in %gs",
|
||||
_RETRY_BACKOFF_SECONDS,
|
||||
)
|
||||
await self._sleep(_RETRY_BACKOFF_SECONDS)
|
||||
continue
|
||||
delay = _delay_seconds(rollover, self._clock())
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep scheduled for %s (%.0fs from now)",
|
||||
settings.day_rollover_time,
|
||||
delay,
|
||||
)
|
||||
await self._sleep(delay)
|
||||
try:
|
||||
await self._run_once(self._clock().date())
|
||||
except Exception:
|
||||
# Not silent either: the sweep error is logged and the
|
||||
# loop backs off before the next cycle, whose watermark
|
||||
# re-read makes an already-swept day a no-op.
|
||||
LOGGER.exception(
|
||||
"NestQuest scheduled missed sweep failed; retrying in "
|
||||
"%gs",
|
||||
_RETRY_BACKOFF_SECONDS,
|
||||
)
|
||||
await self._sleep(_RETRY_BACKOFF_SECONDS)
|
||||
|
||||
async def _run_once(self, today: datetime.date) -> None:
|
||||
"""Run ONE sweep for ``today`` and publish what it built.
|
||||
|
||||
A failure propagates to :meth:`_run_forever`, which logs it and
|
||||
backs off before the next cycle; the watermark makes a re-run
|
||||
of an already-swept day announce nothing twice.
|
||||
"""
|
||||
events = await core_sweep.run_missed_sweep(
|
||||
self._database, today=today
|
||||
)
|
||||
for event_type, payload in events:
|
||||
if self._publisher is not None:
|
||||
self._publisher.publish(event_type, payload)
|
||||
LOGGER.info(
|
||||
"NestQuest scheduled missed sweep for %s published %d event(s)",
|
||||
today.isoformat(),
|
||||
len(events),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MissedSweepScheduler"]
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Reusable SSE transition publishers for the API service (task 44c93cfd).
|
||||
|
||||
Each helper wraps the ONE publisher on ``app.state.publisher`` with a
|
||||
single documented transition: it builds the payload through the core
|
||||
event builders (``nestquest_core.events``) — never hand-built here —
|
||||
and fans it out with :meth:`~api.events.TransitionPublisher.publish`.
|
||||
|
||||
The uncomplete route (follow-up task da0226b3) and the API-side missed
|
||||
sweep do not exist yet; these helpers are the functions they will call,
|
||||
and the SSE tests exercise them directly. Fan-out stays fire-and-forget
|
||||
(see api/events.py): a slow subscriber drops the event and recovers by
|
||||
polling the snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from api.nestquest_core import core_events
|
||||
|
||||
|
||||
async def publish_quest_uncompleted(
|
||||
database: object, instance_id: int, publisher: object
|
||||
) -> None:
|
||||
"""Publish ONE ``nestquest_quest_uncompleted`` event for an actual
|
||||
reversal of ``instance_id``.
|
||||
|
||||
The payload is built by the core builder
|
||||
(:func:`nestquest_core.events.build_quest_uncompleted_events`);
|
||||
this helper only picks the single event out of the builder's list
|
||||
and publishes it.
|
||||
"""
|
||||
events = await core_events.build_quest_uncompleted_events(
|
||||
database, instance_id
|
||||
)
|
||||
for event_type, payload in events:
|
||||
publisher.publish(event_type, payload)
|
||||
|
||||
|
||||
async def publish_quest_missed(
|
||||
database: object, instance_id: int, publisher: object
|
||||
) -> None:
|
||||
"""Publish ONE ``nestquest_quest_missed`` event for ``instance_id``.
|
||||
|
||||
The payload is built by the core builder
|
||||
(:func:`nestquest_core.events.build_quest_missed_event`), which
|
||||
mirrors the documented sweep payload exactly (§3 of
|
||||
design/ENTITIES-AND-SERVICES.md).
|
||||
"""
|
||||
event_type, payload = await core_events.build_quest_missed_event(
|
||||
database, instance_id
|
||||
)
|
||||
publisher.publish(event_type, payload)
|
||||
|
||||
|
||||
async def publish_child_day_complete(
|
||||
database: object,
|
||||
completed_payload: dict,
|
||||
today: datetime.date,
|
||||
publisher: object,
|
||||
) -> bool:
|
||||
"""Publish ``nestquest_child_day_complete`` when a completion
|
||||
cleared the child's whole day; return whether one was published.
|
||||
|
||||
The day-complete rule lives entirely in the core builder
|
||||
(:func:`nestquest_core.events.build_child_day_complete_event`):
|
||||
it returns ``None`` when the day did not clear (a zero-quest day
|
||||
or a non-today instance) and this helper publishes nothing then.
|
||||
"""
|
||||
event = await core_events.build_child_day_complete_event(
|
||||
database, completed_payload, today=today
|
||||
)
|
||||
if event is None:
|
||||
return False
|
||||
event_type, payload = event
|
||||
publisher.publish(event_type, payload)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"publish_child_day_complete",
|
||||
"publish_quest_missed",
|
||||
"publish_quest_uncompleted",
|
||||
]
|
||||
@@ -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,108 @@ 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,
|
||||
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 +544,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 +565,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,216 +7,105 @@ 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).
|
||||
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).
|
||||
|
||||
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.)
|
||||
|
||||
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 dataclasses import dataclass
|
||||
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 .completion import derive_state
|
||||
from .const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_SNAPSHOT_STALENESS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
)
|
||||
from .core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import (
|
||||
MAX_PREVIEW_SCAN_DAYS,
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
PresenceSchedule,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ChildDaySnapshot",
|
||||
"NestQuestCoordinator",
|
||||
"NestQuestSnapshot",
|
||||
"QuestInstanceView",
|
||||
]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestInstanceView:
|
||||
"""One instance plus everything the entities render from it."""
|
||||
|
||||
instance_id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
state: str # open | done | missed
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot:
|
||||
"""Reconstruct the core snapshot dataclasses from the API's JSON.
|
||||
@dataclass(frozen=True)
|
||||
class ChildDaySnapshot:
|
||||
"""One active child's day: instances, presence, and rollup counts."""
|
||||
|
||||
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"],
|
||||
)
|
||||
child_id: int
|
||||
child_name: str
|
||||
instances: tuple[QuestInstanceView, ...]
|
||||
present: bool
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
#: For a child away today: the ISO date of their next present day
|
||||
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
#: from it). ``None`` when the child is present today or no
|
||||
#: present day exists within the presence engine's scan cap.
|
||||
next_present: str | None = None
|
||||
|
||||
|
||||
class _UnconfiguredApiClient:
|
||||
"""Client-shaped stand-in used when the entry has no panel token.
|
||||
@dataclass(frozen=True)
|
||||
class NestQuestSnapshot:
|
||||
"""The coordinator's whole-entity data payload."""
|
||||
|
||||
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()
|
||||
today_iso: str
|
||||
children: tuple[ChildDaySnapshot, ...]
|
||||
cycle_day: int
|
||||
|
||||
|
||||
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 +113,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 +120,8 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry_id: str,
|
||||
api_client: NestQuestApiClient,
|
||||
database: NestQuestDatabase,
|
||||
update_interval_seconds: int | None = None,
|
||||
staleness_seconds: int | None = None,
|
||||
) -> None:
|
||||
interval = update_interval_seconds
|
||||
if (
|
||||
@@ -251,19 +130,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 +137,8 @@ class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
update_interval=datetime.timedelta(seconds=interval),
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.api_client = api_client
|
||||
self.database = database
|
||||
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 +147,146 @@ 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.
|
||||
now = self._local_now()
|
||||
today = now.date()
|
||||
today_iso = today.isoformat()
|
||||
|
||||
A successful pass rebuilds the snapshot and refreshes the
|
||||
last-good cache (the snapshot plus the
|
||||
:func:`time.monotonic` instant of the success).
|
||||
children = await ChildrenDao(self.database).list_active()
|
||||
|
||||
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
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
overrides: dict[int, list[PresenceOverride]] = {}
|
||||
for child in children:
|
||||
schedule = await PresenceSchedulesDao(self.database).get_by_child(
|
||||
child.id
|
||||
)
|
||||
raise UpdateFailed(str(err)) from err
|
||||
snapshot = snapshot_from_api_payload(payload)
|
||||
self._last_good_snapshot = snapshot
|
||||
self._last_good_monotonic = _monotonic()
|
||||
return snapshot
|
||||
if schedule is not None:
|
||||
schedules[child.id] = PresenceSchedule.decode(
|
||||
child.id, schedule.anchor_date, schedule.pattern
|
||||
)
|
||||
window_end = today + datetime.timedelta(
|
||||
days=MAX_PREVIEW_SCAN_DAYS
|
||||
)
|
||||
window_end_iso = window_end.isoformat()
|
||||
ranged = await PresenceOverridesDao(
|
||||
self.database
|
||||
).list_by_child_and_range(child.id, today_iso, window_end_iso)
|
||||
if ranged:
|
||||
# The engine validates MODEL objects, not DAO records;
|
||||
# each record converts losslessly (the schema guarantees
|
||||
# end >= start and a real is_present).
|
||||
overrides[child.id] = [
|
||||
PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in ranged
|
||||
]
|
||||
engine = PresenceEngine(schedules, overrides)
|
||||
|
||||
instances_dao = QuestInstancesDao(self.database)
|
||||
events_dao = CompletionEventsDao(self.database)
|
||||
definitions_dao = QuestDefinitionsDao(self.database)
|
||||
|
||||
snapshots: list[ChildDaySnapshot] = []
|
||||
for child in children:
|
||||
instances = await instances_dao.list_by_date_range(
|
||||
child.id, today_iso, today_iso
|
||||
)
|
||||
views: list[QuestInstanceView] = []
|
||||
for instance in instances:
|
||||
latest = await events_dao.get_latest_for_instance(instance.id)
|
||||
definition = await definitions_dao.get(instance.definition_id)
|
||||
state = derive_state(instance, latest, today)
|
||||
overdue = False
|
||||
if state == "open" and instance.due_date == today_iso:
|
||||
if instance.due_time is not None:
|
||||
due_moment = datetime.datetime.strptime(
|
||||
instance.due_time, "%H:%M"
|
||||
).time()
|
||||
overdue = now.time() > due_moment
|
||||
views.append(
|
||||
QuestInstanceView(
|
||||
instance_id=instance.id,
|
||||
definition_id=instance.definition_id,
|
||||
child_id=instance.child_id,
|
||||
title=definition.title if definition else "Quest",
|
||||
icon=definition.icon if definition else None,
|
||||
window=instance.window,
|
||||
due_date=instance.due_date,
|
||||
due_time=instance.due_time,
|
||||
state=state,
|
||||
overdue=overdue,
|
||||
completed_at=(
|
||||
latest.occurred_at
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
was_on_time=(
|
||||
latest.was_on_time
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
due = len(views)
|
||||
completed = sum(1 for view in views if view.state == "done")
|
||||
remaining = due - completed
|
||||
child_present = engine.is_present(child.id, today)
|
||||
next_present: str | None = None
|
||||
if not child_present:
|
||||
try:
|
||||
next_present = engine.next_present_dates(
|
||||
child.id, today, 1
|
||||
)[0].isoformat()
|
||||
except ValueError:
|
||||
next_present = None
|
||||
snapshots.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child.id,
|
||||
child_name=child.display_name,
|
||||
instances=tuple(views),
|
||||
present=child_present,
|
||||
due_today=due,
|
||||
completed_today=completed,
|
||||
remaining_today=remaining,
|
||||
completion_pct=(
|
||||
round(completed * 100 / due) if due else 100
|
||||
),
|
||||
next_present=next_present,
|
||||
)
|
||||
)
|
||||
|
||||
return NestQuestSnapshot(
|
||||
today_iso=today_iso,
|
||||
children=tuple(snapshots),
|
||||
cycle_day=_cycle_day(schedules, children, today),
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
def _cycle_day(
|
||||
schedules: dict[int, PresenceSchedule], children, today: datetime.date
|
||||
) -> int:
|
||||
"""Return today's 1-based day in the first scheduled child's cycle.
|
||||
|
||||
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``.
|
||||
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
|
||||
the schedule's own anchor, never ISO week numbers or parity, so a
|
||||
53-week year cannot silently invert the cycle.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
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] = {}
|
||||
for child in children:
|
||||
schedule = schedules.get(child.id)
|
||||
if schedule is None:
|
||||
continue
|
||||
days = (today - schedule.anchor_date).days
|
||||
cycle_length = schedule.cycle_length_weeks * 7
|
||||
return (days % cycle_length) + 1
|
||||
return 0
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,7 +764,6 @@ 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,
|
||||
) -> QuestDefinitionSnapshot:
|
||||
"""Edit a definition's metadata, rule and windows atomically.
|
||||
|
||||
@@ -806,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
|
||||
@@ -825,8 +792,6 @@ class QuestDefinitionsDao:
|
||||
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)
|
||||
@@ -844,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(
|
||||
@@ -945,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]:
|
||||
@@ -996,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.
|
||||
|
||||
@@ -1047,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
|
||||
@@ -1073,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)
|
||||
|
||||
@@ -14,9 +14,7 @@ fires nothing), and ``nestquest_child_day_complete`` fires when a
|
||||
completion clears the child's whole day (quests were owed and none
|
||||
remain — a zero-quest day can never fire it, matching the all-done
|
||||
binary sensor's rule). ``nestquest_quest_missed`` is the documented
|
||||
contract Feature 11's nightly sweep fires; :func:`build_quest_missed_event`
|
||||
builds it with exactly the payload the sweep hand-builds (§3), so the
|
||||
API plane can publish the same shape without re-deriving it.
|
||||
contract Feature 11's nightly sweep fires; nothing here builds it.
|
||||
|
||||
The completed-event and day-complete-event builders are split so the
|
||||
shim can fire ``nestquest_quest_completed`` BEFORE it evaluates the
|
||||
@@ -52,7 +50,6 @@ from .completion import derive_state
|
||||
from .const import (
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_QUEST_MISSED,
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
)
|
||||
from .dao_children import ChildrenDao
|
||||
@@ -191,20 +188,3 @@ async def build_quest_uncompleted_events(
|
||||
"""
|
||||
payload = await _instance_payload(database, instance_id, _now_stamp())
|
||||
return [(EVENT_QUEST_UNCOMPLETED, payload)]
|
||||
|
||||
|
||||
async def build_quest_missed_event(
|
||||
database: NestQuestDatabase, instance_id: int
|
||||
) -> tuple[str, dict]:
|
||||
"""Build the ``nestquest_quest_missed`` event for one instance.
|
||||
|
||||
The payload mirrors the nightly sweep's hand-built payload exactly
|
||||
(the shared :func:`_instance_payload` shape: ``child_id``,
|
||||
``child_name``, ``instance_id``, ``quest_title``, ``window``,
|
||||
``due_date``, ``due_time``, ``occurred_at``), so the API plane's
|
||||
missed path publishes the same event shape the integration's sweep
|
||||
fires. Like the sweep, this builder never mutates the instance or
|
||||
writes a completion event — it reads only.
|
||||
"""
|
||||
payload = await _instance_payload(database, instance_id, _now_stamp())
|
||||
return (EVENT_QUEST_MISSED, payload)
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
"""Read-only history layer for the admin plane (Admin spec §5, task 2d2dda53).
|
||||
|
||||
Assembles the History screen's event rows over the append-only
|
||||
completion log: a query over a CLOSED date range ``[start_date,
|
||||
end_date]`` for one of three filters —
|
||||
|
||||
- ``all`` — every completion event (completed AND uncompleted) whose
|
||||
instance is due in the range;
|
||||
- ``reversals`` — the uncompleted events only;
|
||||
- ``missed`` — the DERIVED missed instances in the range.
|
||||
|
||||
Range semantics (Feature 13's rule, kept): the range scopes by the
|
||||
INSTANCE's ``due_date`` — the day the task was owed — not the event's
|
||||
wall-clock ``occurred_at``, because history reporting asks "what
|
||||
happened to the tasks due that day". ``missed`` is derived, never
|
||||
stored: rows come from :func:`~.completion.list_missed_for_child`
|
||||
(past-due open instances, the same ``_derived_state`` rule the
|
||||
coordinator uses), and nothing is ever appended for them.
|
||||
|
||||
Row shape: the Admin spec §5 event-row fields, fixed and documented in
|
||||
:data:`CSV_COLUMNS` — ``occurred_at``, ``event_type``,
|
||||
``child_name``, ``child_id``, ``quest_title``, ``instance_id``,
|
||||
``window``, ``due_date``, ``due_time``, ``actor``, ``was_on_time``.
|
||||
Derived missed rows carry ``event_type`` ``'missed'`` (never a stored
|
||||
event_type), no ``occurred_at`` (there is no event), ``was_on_time``
|
||||
``None`` and the spec's ``nightly sweep`` actor. A stored event's
|
||||
``actor`` follows the spec's meta shape: the ``actor_user_id`` string
|
||||
for a user event (``joshua``), ``panel (Child)`` naming the tapped
|
||||
profile for a panel event.
|
||||
|
||||
Validation policy (module-level, shared by both the JSON query and the
|
||||
CSV export): the filter must be one of :data:`FILTERS` and the range
|
||||
must be two strict ``YYYY-MM-DD`` dates with ``end >= start``;
|
||||
violations raise ``ValueError`` naming the offending field
|
||||
(``filter`` / ``start`` / ``end``) BEFORE any read. The whole
|
||||
household read runs under the task-reentrant connection lock so every
|
||||
row of one answer is one snapshot — the same discipline
|
||||
:mod:`.completion` gives its read-decide-write paths.
|
||||
|
||||
:func:`rows_to_csv` serializes rows with the :mod:`csv` module (proper
|
||||
quoting, one stable header row) and :func:`export_history_csv` runs
|
||||
query + serialization as ONE call, so the CSV export can never drift
|
||||
from the JSON query's filtered range. No permission check lives here
|
||||
(that is the admin router's single ``require_admin`` dependency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import datetime
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .completion import _reentrant_connection_lock, list_missed_for_child
|
||||
from .dao_children import ChildRecord, ChildrenDao
|
||||
from .dao_instances import (
|
||||
EVENT_UNCOMPLETED,
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
QuestInstanceRecord,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from .dao_rules import QuestDefinitionsDao, _validate_date
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: The ``all`` filter: every completion event in the range.
|
||||
FILTER_ALL = "all"
|
||||
|
||||
#: The ``reversals`` filter: uncompleted events only.
|
||||
FILTER_REVERSALS = "reversals"
|
||||
|
||||
#: The ``missed`` filter: derived missed instances in the range.
|
||||
FILTER_MISSED = "missed"
|
||||
|
||||
#: The filters the query accepts, in the Admin spec's chip order.
|
||||
FILTERS = (FILTER_ALL, FILTER_REVERSALS, FILTER_MISSED)
|
||||
|
||||
#: The ``event_type`` a derived missed row reports. ``missed`` is a
|
||||
#: DERIVED state, never a stored event_type (Feature 08).
|
||||
EVENT_MISSED = "missed"
|
||||
|
||||
#: The actor a derived missed row reports (Admin spec §5 meta:
|
||||
#: ``missed · nightly sweep``).
|
||||
ACTOR_NIGHTLY_SWEEP = "nightly sweep"
|
||||
|
||||
#: The CSV column set, in header order — the Admin spec §5 event-row
|
||||
#: fields. The header row is exactly this tuple, always, so a consumer
|
||||
#: can key on column names.
|
||||
CSV_COLUMNS = (
|
||||
"occurred_at",
|
||||
"event_type",
|
||||
"child_name",
|
||||
"child_id",
|
||||
"quest_title",
|
||||
"instance_id",
|
||||
"window",
|
||||
"due_date",
|
||||
"due_time",
|
||||
"actor",
|
||||
"was_on_time",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HistoryRow:
|
||||
"""One history row: a stored event or a derived missed instance.
|
||||
|
||||
The fields are :data:`CSV_COLUMNS` in order. For a derived missed
|
||||
row ``occurred_at`` is ``None`` (no event exists) and ``was_on_time``
|
||||
is ``None``; for a stored event ``event_type`` is
|
||||
``'completed'``/``'uncompleted'`` and the fields come from the event
|
||||
joined with its instance, child and definition.
|
||||
"""
|
||||
|
||||
occurred_at: str | None
|
||||
event_type: str
|
||||
child_name: str
|
||||
child_id: int
|
||||
quest_title: str
|
||||
instance_id: int
|
||||
window: str
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
actor: str
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
def _validate_filter(filter_name: str) -> str:
|
||||
"""Return the filter, or raise ValueError naming the field."""
|
||||
if filter_name not in FILTERS:
|
||||
raise ValueError(
|
||||
f"filter must be one of {', '.join(repr(f) for f in FILTERS)}, "
|
||||
f"got {filter_name!r}"
|
||||
)
|
||||
return filter_name
|
||||
|
||||
|
||||
def _validate_range(start_date: str, end_date: str) -> tuple[str, str]:
|
||||
"""Validate the closed range and return it, else raise naming the field.
|
||||
|
||||
Both bounds must be strict ``YYYY-MM-DD`` dates (the shared
|
||||
:func:`~.dao_rules._validate_date`, so non-padded or re-ordered
|
||||
shapes are rejected and a non-string raises naming the field) and
|
||||
``end`` must be on or after ``start``.
|
||||
"""
|
||||
_validate_date(start_date, "start")
|
||||
_validate_date(end_date, "end")
|
||||
if end_date < start_date:
|
||||
raise ValueError(
|
||||
f"end must be on or after start, got {end_date!r} < "
|
||||
f"{start_date!r}"
|
||||
)
|
||||
return start_date, end_date
|
||||
|
||||
|
||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
"""Return the threaded HA-local date, or the host calendar date."""
|
||||
if today is None:
|
||||
return datetime.date.today()
|
||||
return today
|
||||
|
||||
|
||||
def _event_actor(
|
||||
event: CompletionEventRecord, actor_child: ChildRecord | None
|
||||
) -> str:
|
||||
"""Return the spec-meta actor string for one stored event.
|
||||
|
||||
A user event reports the ``actor_user_id`` string (the spec's
|
||||
``uncompleted · joshua (admin)`` shape); a panel event reports
|
||||
``panel (Child)`` naming the tapped profile. The tapped child is
|
||||
never hard-deleted, so the lookup below is a belt-and-braces
|
||||
fallback to the stored id, never a silent rewrite of policy.
|
||||
"""
|
||||
if event.actor_source == "user":
|
||||
return event.actor_user_id or ""
|
||||
if actor_child is not None:
|
||||
return f"panel ({actor_child.display_name})"
|
||||
return f"panel ({event.actor_child_id})"
|
||||
|
||||
|
||||
def _event_row(
|
||||
event: CompletionEventRecord,
|
||||
instance: QuestInstanceRecord,
|
||||
child: ChildRecord,
|
||||
quest_title: str,
|
||||
actor_child: ChildRecord | None,
|
||||
) -> HistoryRow:
|
||||
"""Join one stored event with its instance, child and definition."""
|
||||
return HistoryRow(
|
||||
occurred_at=event.occurred_at,
|
||||
event_type=event.event_type,
|
||||
child_name=child.display_name,
|
||||
child_id=event.child_id,
|
||||
quest_title=quest_title,
|
||||
instance_id=event.instance_id,
|
||||
window=instance.window,
|
||||
due_date=instance.due_date,
|
||||
due_time=instance.due_time,
|
||||
actor=_event_actor(event, actor_child),
|
||||
was_on_time=event.was_on_time,
|
||||
)
|
||||
|
||||
|
||||
def _missed_row(
|
||||
instance: QuestInstanceRecord,
|
||||
child: ChildRecord,
|
||||
quest_title: str,
|
||||
) -> HistoryRow:
|
||||
"""Build the derived row for one past-due open instance."""
|
||||
return HistoryRow(
|
||||
occurred_at=None,
|
||||
event_type=EVENT_MISSED,
|
||||
child_name=child.display_name,
|
||||
child_id=instance.child_id,
|
||||
quest_title=quest_title,
|
||||
instance_id=instance.id,
|
||||
window=instance.window,
|
||||
due_date=instance.due_date,
|
||||
due_time=instance.due_time,
|
||||
actor=ACTOR_NIGHTLY_SWEEP,
|
||||
was_on_time=None,
|
||||
)
|
||||
|
||||
|
||||
def _csv_values(row: HistoryRow) -> list[str]:
|
||||
"""Return one CSV row's cell values in :data:`CSV_COLUMNS` order.
|
||||
|
||||
``None`` cells serialize as empty (a missed row has no
|
||||
``occurred_at``); ``was_on_time`` serializes as ``true``/``false``
|
||||
when the event carries the flag and empty when it does not.
|
||||
"""
|
||||
return [
|
||||
row.occurred_at or "",
|
||||
row.event_type,
|
||||
row.child_name,
|
||||
str(row.child_id),
|
||||
row.quest_title,
|
||||
str(row.instance_id),
|
||||
row.window,
|
||||
row.due_date,
|
||||
row.due_time or "",
|
||||
row.actor,
|
||||
"" if row.was_on_time is None else str(row.was_on_time).lower(),
|
||||
]
|
||||
|
||||
|
||||
def rows_to_csv(rows: list[HistoryRow]) -> str:
|
||||
"""Serialize history rows to CSV text with the documented header.
|
||||
|
||||
The FIRST row is always :data:`CSV_COLUMNS`, so the header is
|
||||
stable and deterministic; the :mod:`csv` module handles quoting
|
||||
(titles or names containing commas or quotes are quoted per RFC
|
||||
4180) and CRLF line endings.
|
||||
"""
|
||||
buffer = io.StringIO()
|
||||
writer = csv.writer(buffer)
|
||||
writer.writerow(CSV_COLUMNS)
|
||||
for row in rows:
|
||||
writer.writerow(_csv_values(row))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
async def query_history(
|
||||
database: NestQuestDatabase,
|
||||
filter_name: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> list[HistoryRow]:
|
||||
"""Return the history rows for ``filter_name`` over the closed range.
|
||||
|
||||
Validates the filter and the strict ``YYYY-MM-DD`` range (raising
|
||||
``ValueError`` naming ``filter`` / ``start`` / ``end``) BEFORE any
|
||||
read, then builds the rows:
|
||||
|
||||
- ``all`` / ``reversals``: every child's events whose instances are
|
||||
due in the range (:meth:`~.dao_instances.CompletionEventsDao.list_by_child_and_date_range`,
|
||||
the Feature 13 due-date scope), ``reversals`` keeping only the
|
||||
``uncompleted`` rows.
|
||||
- ``missed``: :func:`~.completion.list_missed_for_child` per child —
|
||||
the SAME derived-state rule the coordinator uses, never a new one,
|
||||
and never a stored event.
|
||||
|
||||
Rows are ordered deterministically by ``due_date``, then instance,
|
||||
then event id (the per-instance append order, so a completion and
|
||||
its reversal keep their order). ``today`` pins the HA-local date
|
||||
the missed derivation compares due dates against (the host calendar
|
||||
date when omitted). The whole read runs under the task-reentrant
|
||||
connection lock — :func:`list_missed_for_child` re-enters it — so
|
||||
one answer is one snapshot.
|
||||
"""
|
||||
filter_name = _validate_filter(filter_name)
|
||||
start_date, end_date = _validate_range(start_date, end_date)
|
||||
today_date = _resolve_today(today)
|
||||
|
||||
keyed: list[tuple[tuple[str, int, int], HistoryRow]] = []
|
||||
async with _reentrant_connection_lock(database):
|
||||
children = await ChildrenDao(database).list_all()
|
||||
children_by_id = {child.id: child for child in children}
|
||||
instances = QuestInstancesDao(database)
|
||||
definitions = QuestDefinitionsDao(database)
|
||||
events = CompletionEventsDao(database)
|
||||
titles: dict[int, str] = {}
|
||||
|
||||
if filter_name == FILTER_MISSED:
|
||||
for child in children:
|
||||
for instance in await list_missed_for_child(
|
||||
database, child.id, start_date, end_date, today=today_date
|
||||
):
|
||||
if instance.definition_id not in titles:
|
||||
definition = await definitions.get(
|
||||
instance.definition_id
|
||||
)
|
||||
assert definition is not None, (
|
||||
"a materialized instance's definition exists"
|
||||
)
|
||||
titles[instance.definition_id] = definition.title
|
||||
keyed.append(
|
||||
(
|
||||
(instance.due_date, instance.id, 0),
|
||||
_missed_row(
|
||||
instance,
|
||||
child,
|
||||
titles[instance.definition_id],
|
||||
),
|
||||
)
|
||||
)
|
||||
keyed.sort(key=lambda pair: pair[0])
|
||||
return [row for _, row in keyed]
|
||||
|
||||
for child in children:
|
||||
for event in await events.list_by_child_and_date_range(
|
||||
child.id, start_date, end_date
|
||||
):
|
||||
if filter_name == FILTER_REVERSALS and (
|
||||
event.event_type != EVENT_UNCOMPLETED
|
||||
):
|
||||
continue
|
||||
instance = await instances.get_by_id(event.instance_id)
|
||||
assert instance is not None, (
|
||||
"an event's instance is never deleted (append-only "
|
||||
"history keeps its references)"
|
||||
)
|
||||
actor_child = (
|
||||
children_by_id.get(event.actor_child_id)
|
||||
if event.actor_child_id is not None
|
||||
else None
|
||||
)
|
||||
if instance.definition_id not in titles:
|
||||
definition = await definitions.get(instance.definition_id)
|
||||
assert definition is not None, (
|
||||
"a materialized instance's definition exists"
|
||||
)
|
||||
titles[instance.definition_id] = definition.title
|
||||
keyed.append(
|
||||
(
|
||||
(instance.due_date, instance.id, event.id),
|
||||
_event_row(
|
||||
event,
|
||||
instance,
|
||||
child,
|
||||
titles[instance.definition_id],
|
||||
actor_child,
|
||||
),
|
||||
)
|
||||
)
|
||||
keyed.sort(key=lambda pair: pair[0])
|
||||
return [row for _, row in keyed]
|
||||
|
||||
|
||||
async def export_history_csv(
|
||||
database: NestQuestDatabase,
|
||||
filter_name: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> str:
|
||||
"""Return the filtered history as CSV text, header row included.
|
||||
|
||||
ONE query + serialization call so the CSV export carries exactly
|
||||
the JSON query's filtered range and order — the same
|
||||
:func:`query_history` validation, rows and :func:`rows_to_csv`
|
||||
serializer, never a second implementation.
|
||||
"""
|
||||
rows = await query_history(
|
||||
database, filter_name, start_date, end_date, today=today
|
||||
)
|
||||
return rows_to_csv(rows)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACTOR_NIGHTLY_SWEEP",
|
||||
"CSV_COLUMNS",
|
||||
"EVENT_MISSED",
|
||||
"EVENT_UNCOMPLETED",
|
||||
"FILTERS",
|
||||
"FILTER_ALL",
|
||||
"FILTER_MISSED",
|
||||
"FILTER_REVERSALS",
|
||||
"HistoryRow",
|
||||
"export_history_csv",
|
||||
"query_history",
|
||||
"rows_to_csv",
|
||||
]
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
"""Business layer for presence writes (Feature 09, API admin plane).
|
||||
|
||||
Sits between the write callers (the Feature 09 HA service handlers and
|
||||
the API admin plane's presence routes) and the typed presence DAOs plus
|
||||
the pure presence model: callers get the model validation, the upsert /
|
||||
create / delete operations and the regeneration side effect here, and
|
||||
never touch the DAO or SQL directly. This is the ONE implementation of
|
||||
these business rules — every caller constructs the same
|
||||
:class:`~.presence.PresenceSchedule` / :class:`~.presence.PresenceOverride`
|
||||
models (whose constructors are total, so no re-validation happens in a
|
||||
handler) and gets the same post-write
|
||||
:func:`~.materialize.regenerate_for_child`, so a child's future quest
|
||||
instances track its changed presence no matter which plane issued the
|
||||
write. Like the model it is Home-Assistant-free: ``today`` is a plain
|
||||
calendar date threaded through to the regeneration, defaulting to the
|
||||
host clock there.
|
||||
|
||||
Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence
|
||||
schedule — setting again REPLACES the existing row rather than
|
||||
duplicating it. Overrides are deletable by design (children are not —
|
||||
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:``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from .dao_presence import (
|
||||
PresenceOverrideRecord,
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import regenerate_for_child
|
||||
from .presence import PresenceOverride, PresenceSchedule
|
||||
|
||||
|
||||
def _validate_override_id(value: object) -> int:
|
||||
"""Reject non-int override ids (bools would silently address id 1)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"override_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def set_presence_schedule(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
cycle_length_weeks: int,
|
||||
anchor_date: object,
|
||||
pattern: dict[int, object],
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> PresenceSchedule:
|
||||
"""Set (upsert) a child's repeating presence schedule.
|
||||
|
||||
The :class:`~.presence.PresenceSchedule` model validates at
|
||||
construction — cycle length 1..4, a strict calendar anchor date,
|
||||
and a pattern covering week indices 0..n-1 with weekday sets — so
|
||||
no invalid schedule reaches the DAO. The encoded pattern is
|
||||
UPSERTed by child (a second set REPLACES the child's existing
|
||||
schedule; the schema's UNIQUE(child_id) makes duplication
|
||||
unrepresentable), then :func:`~.materialize.regenerate_for_child`
|
||||
re-materializes the child's future instances against the new
|
||||
presence. ``today``/``horizon_days`` thread through to that
|
||||
regeneration; raising ValueError names what to fix (an unknown
|
||||
child reads ``child N does not exist``).
|
||||
|
||||
Returns the STORED schedule decoded back through the model — the
|
||||
round-trip proves the encoded pattern the database now holds is
|
||||
exactly the one the caller asked for.
|
||||
"""
|
||||
schedule = PresenceSchedule(
|
||||
child_id, cycle_length_weeks, anchor_date, pattern
|
||||
)
|
||||
record = await PresenceSchedulesDao(database).upsert_by_child(
|
||||
schedule.child_id,
|
||||
schedule.cycle_length_weeks,
|
||||
schedule.anchor_date.isoformat(),
|
||||
schedule.encode(),
|
||||
)
|
||||
await regenerate_for_child(
|
||||
database, schedule.child_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return PresenceSchedule.decode(
|
||||
record.child_id, record.anchor_date, record.pattern
|
||||
)
|
||||
|
||||
|
||||
async def create_presence_override(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
start_date: object,
|
||||
end_date: object,
|
||||
is_present: bool,
|
||||
note: str | None = None,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> PresenceOverrideRecord:
|
||||
"""Create one date-range presence override and return it as stored.
|
||||
|
||||
The :class:`~.presence.PresenceOverride` model validates at
|
||||
construction — strict calendar dates, ``end_date`` on or after
|
||||
``start_date``, a real ``is_present`` bool, a string-or-None note —
|
||||
then the DAO insert applies the same-child no-overlap policy (a
|
||||
conflicting range raises ValueError naming the existing override)
|
||||
and :func:`~.materialize.regenerate_for_child` re-materializes the
|
||||
child's future instances against the override's presence answer.
|
||||
An unknown child reads ``child N does not exist``.
|
||||
|
||||
Returns the stored record WITH its ``id`` — the handle a later
|
||||
:func:`delete_presence_override` takes.
|
||||
"""
|
||||
override = PresenceOverride(
|
||||
child_id, start_date, end_date, is_present, note=note
|
||||
)
|
||||
record = await PresenceOverridesDao(database).create(
|
||||
override.child_id,
|
||||
override.start_date.isoformat(),
|
||||
override.end_date.isoformat(),
|
||||
override.is_present,
|
||||
note=override.note,
|
||||
)
|
||||
await regenerate_for_child(
|
||||
database, override.child_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
async def delete_presence_override(
|
||||
database: NestQuestDatabase,
|
||||
override_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> PresenceOverrideRecord:
|
||||
"""Delete one presence override by id and return it as it was stored.
|
||||
|
||||
The override is looked up FIRST and an unknown id raises ValueError
|
||||
prefixed ``override_id:`` (naming the id) BEFORE any delete, so a
|
||||
mistyped id can never be silently swallowed by a no-op. The
|
||||
deletion is followed by :func:`~.materialize.regenerate_for_child`
|
||||
for the override's own child — the id decides the child, never a
|
||||
caller-supplied one. ``today``/``horizon_days`` thread through to
|
||||
that regeneration.
|
||||
"""
|
||||
_validate_override_id(override_id)
|
||||
dao = PresenceOverridesDao(database)
|
||||
override = await dao.get(override_id)
|
||||
if override is None:
|
||||
raise ValueError(
|
||||
f"override_id: presence override {override_id} does not exist"
|
||||
)
|
||||
await dao.delete(override_id)
|
||||
await regenerate_for_child(
|
||||
database, override.child_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return override
|
||||
@@ -191,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.
|
||||
|
||||
@@ -223,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.
|
||||
|
||||
@@ -243,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)
|
||||
@@ -256,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
|
||||
@@ -285,7 +271,6 @@ 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,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
@@ -300,9 +285,7 @@ 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. Assignment is NOT
|
||||
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.
|
||||
|
||||
@@ -348,10 +331,6 @@ async def edit_quest_definition(
|
||||
if windows is not _UNSET:
|
||||
window_specs = _normalize_windows(windows)
|
||||
|
||||
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(
|
||||
@@ -361,7 +340,6 @@ async def edit_quest_definition(
|
||||
icon=icon_value,
|
||||
rule=rule_storage,
|
||||
windows=window_specs,
|
||||
skip_on_away=skip_on_away_value,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's missing-definition error names only the id; re-raise
|
||||
@@ -580,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)}"
|
||||
)
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Durable settings store for the NestQuest API service.
|
||||
|
||||
The API service has NO Home Assistant config entry, so it cannot build
|
||||
:class:`~.settings.NestQuestSettings` from ``entry.options`` the way the
|
||||
integration does. This module gives the API its own durable settings
|
||||
source: a JSON document kept in the EXISTING ``nestquest_meta_state``
|
||||
key/value table (:class:`~.dao_meta.MetaStateDao`) under the well-known
|
||||
key :data:`SETTINGS_META_KEY` — no schema change, no new table —
|
||||
validated through the SAME field table the settings object is built
|
||||
from (:data:`~.settings._FIELDS`, the table driving
|
||||
``from_options``/``from_options_resilient``). No validation rule is
|
||||
re-implemented here.
|
||||
|
||||
OWNERSHIP: the API now owns settings in the database. The
|
||||
integration's Home-Assistant config-entry options remain a temporary,
|
||||
SEPARATE source until Feature 18 converts the integration into a thin
|
||||
API client; the two are deliberately NOT unified here, and neither
|
||||
side reads the other's values.
|
||||
|
||||
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
|
||||
object holding exactly the TEN settings fields, keyed by field name
|
||||
(which equals the ``CONF_*`` option key for every field), complete
|
||||
after the first update:
|
||||
|
||||
- ``horizon_days`` — int >= 1,
|
||||
- ``day_rollover_time``, ``morning_summary_time``,
|
||||
``afternoon_reminder_time``, ``end_of_day_report_time`` — strict
|
||||
24-hour ``HH:MM`` strings,
|
||||
- ``notify_target`` — a string (a padded value is stripped by the
|
||||
core's normalizer),
|
||||
- ``morning_summary_enabled``, ``afternoon_reminder_enabled``,
|
||||
``end_of_day_report_enabled``, ``celebration_enabled`` — real
|
||||
booleans.
|
||||
|
||||
Only values whose core validators ACCEPTED are ever written, so a
|
||||
stored document always round-trips through the core's option builders.
|
||||
An absent key (a fresh database) resolves to the all-defaults
|
||||
settings, and a corrupt document degrades to defaults or per-field
|
||||
defaults rather than crashing the service (see :func:`load_settings`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from .const import LOGGER
|
||||
from .dao_meta import MetaStateDao
|
||||
from .db import NestQuestDatabase
|
||||
from .settings import NestQuestSettings, _FIELDS
|
||||
|
||||
#: The well-known ``nestquest_meta_state`` key holding the settings
|
||||
#: document. One key, one JSON object — the same table the missed
|
||||
#: sweep's watermark already uses, so no migration is needed.
|
||||
SETTINGS_META_KEY = "settings"
|
||||
|
||||
#: The per-field resolution table keyed by field name: each row is the
|
||||
#: ``(default, validator)`` pair the settings module's ``_FIELDS`` table
|
||||
#: carries for that field. ``update_settings`` resolves each PROVIDED
|
||||
#: field through its row's validator — the SAME core validators
|
||||
#: ``from_options`` applies — and never re-implements one here.
|
||||
#: (Field names equal the ``CONF_*`` option keys, so one dict serves
|
||||
#: both the API's field names and the stored document's keys.)
|
||||
_RESOLUTIONS: dict[str, tuple[Any, Any]] = {
|
||||
key: (default, validator) for _field, key, default, validator in _FIELDS
|
||||
}
|
||||
|
||||
#: The known field names, in the settings module's field order — the
|
||||
#: allowed keys of an update body and the stored document's key set.
|
||||
_FIELD_NAMES: tuple[str, ...] = tuple(
|
||||
key for _field, key, _default, _validator in _FIELDS
|
||||
)
|
||||
|
||||
#: One asyncio.Lock per (connection wrapper, running loop), the missed
|
||||
#: sweep's pattern: the whole stored-document read → merge → write span
|
||||
#: is one critical section, so two concurrent updates queue instead of
|
||||
#: one losing the other's change — the loser re-reads the document
|
||||
#: INSIDE the lock and merges onto the winner's settings.
|
||||
_SETTINGS_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _settings_lock(database: NestQuestDatabase) -> asyncio.Lock:
|
||||
"""Return the settings lock bound to ``database``'s running loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _SETTINGS_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_SETTINGS_LOCKS[key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
async def _load_document(database: NestQuestDatabase) -> dict[str, Any]:
|
||||
"""Read and decode the stored settings document (empty when unusable).
|
||||
|
||||
An absent key — a fresh database that never updated settings — and
|
||||
an unparseable value (invalid JSON, or JSON that is not an object)
|
||||
return an empty mapping, which the option builders resolve to the
|
||||
all-defaults settings; the corrupt cases log a warning first. A
|
||||
parseable object is returned as stored (its per-field validity is
|
||||
the builder's concern, not this reader's).
|
||||
"""
|
||||
raw = await MetaStateDao(database).get(SETTINGS_META_KEY)
|
||||
if raw is None:
|
||||
return {}
|
||||
try:
|
||||
document = json.loads(raw)
|
||||
except ValueError:
|
||||
LOGGER.warning(
|
||||
"NestQuest stored settings (meta_state key %r) are not valid "
|
||||
"JSON; falling back to the default settings",
|
||||
SETTINGS_META_KEY,
|
||||
)
|
||||
return {}
|
||||
if not isinstance(document, dict):
|
||||
LOGGER.warning(
|
||||
"NestQuest stored settings (meta_state key %r) are not a JSON "
|
||||
"object; falling back to the default settings",
|
||||
SETTINGS_META_KEY,
|
||||
)
|
||||
return {}
|
||||
return document
|
||||
|
||||
|
||||
def _named(field: str, error: ValueError) -> str:
|
||||
"""Return a field-named message for a rejected validator value.
|
||||
|
||||
The horizon, time and notify-target validators already name their
|
||||
field in the message; the boolean validator's message is generic
|
||||
(``expected a boolean, got ...``), so it gets the field prefixed.
|
||||
Every rejected update's error therefore names its field either way.
|
||||
"""
|
||||
message = str(error)
|
||||
if message.startswith(f"{field} "):
|
||||
return message
|
||||
return f"{field}: {message}"
|
||||
|
||||
|
||||
async def load_settings(database: NestQuestDatabase) -> NestQuestSettings:
|
||||
"""Return the current effective settings stored for ``database``.
|
||||
|
||||
Reads :data:`SETTINGS_META_KEY` and resolves the stored document
|
||||
through the core settings validators, with each absent field
|
||||
falling back to its default. An absent key and an unparseable
|
||||
document fall back to the all-defaults settings (via
|
||||
:func:`_load_document`), and a parseable document with a single
|
||||
invalid FIELD falls back PER FIELD — through
|
||||
:meth:`NestQuestSettings.from_options_resilient`, the core's own
|
||||
resilient builder — so a corrupt sibling never resets the stored
|
||||
values around it. This never raises: the service must keep
|
||||
serving reads even over a corrupt store.
|
||||
"""
|
||||
document = await _load_document(database)
|
||||
return NestQuestSettings.from_options_resilient(document)
|
||||
|
||||
|
||||
async def update_settings(
|
||||
database: NestQuestDatabase, changes: Mapping[str, Any]
|
||||
) -> NestQuestSettings:
|
||||
"""Validate ``changes`` and persist them as the effective settings.
|
||||
|
||||
``changes`` maps settings field names to new values. EVERY name is
|
||||
checked against the known field table BEFORE anything is validated
|
||||
or written: an unknown name raises a ``ValueError`` naming it (and
|
||||
the known names), leaving the stored settings untouched. Each
|
||||
PROVIDED field is then resolved from_options-style — its
|
||||
:data:`~.settings._FIELDS` validator applied, with a JSON ``null``
|
||||
mapping to the field's default (the settings module's uniform
|
||||
None-as-absent rule) — and an invalid value raises a ``ValueError``
|
||||
naming the field, also BEFORE anything is written, so a rejected
|
||||
update can never leave a half-applied document behind.
|
||||
|
||||
The validated changes are merged onto the current effective
|
||||
settings and the merged (complete, ten-field) document is persisted
|
||||
in one atomic upsert; the updated settings are returned. The whole
|
||||
read → merge → write span runs inside the per-database settings
|
||||
lock, so concurrent updates queue instead of interleaving.
|
||||
"""
|
||||
if not isinstance(changes, Mapping):
|
||||
raise ValueError(
|
||||
"settings changes must be a JSON object mapping field names "
|
||||
"to values"
|
||||
)
|
||||
unknown = sorted(
|
||||
str(name) for name in changes if name not in _RESOLUTIONS
|
||||
)
|
||||
if unknown:
|
||||
listed = ", ".join(repr(name) for name in unknown)
|
||||
known = ", ".join(_FIELD_NAMES)
|
||||
raise ValueError(
|
||||
f"unknown settings field(s): {listed}; known fields: {known}"
|
||||
)
|
||||
async with _settings_lock(database):
|
||||
current = await load_settings(database)
|
||||
document: dict[str, Any] = {
|
||||
field: getattr(current, field) for field in _FIELD_NAMES
|
||||
}
|
||||
for name, raw_value in changes.items():
|
||||
default, validator = _RESOLUTIONS[name]
|
||||
try:
|
||||
document[name] = validator(
|
||||
default if raw_value is None else raw_value
|
||||
)
|
||||
except ValueError as error:
|
||||
raise ValueError(_named(name, error)) from error
|
||||
await MetaStateDao(database).set(
|
||||
SETTINGS_META_KEY, json.dumps(document, sort_keys=True)
|
||||
)
|
||||
return NestQuestSettings(**document)
|
||||
@@ -1,306 +0,0 @@
|
||||
"""Pure, Home-Assistant-free panel snapshot builder.
|
||||
|
||||
The household snapshot the NestQuest panel renders — active children in
|
||||
sort order, each child's today-instances in the documented payload shape
|
||||
(missed instances OMITTED from the entity-facing attribute payload, but
|
||||
retained on the snapshot for the count rollups), per-child counts and
|
||||
presence, and the custody cycle day — used to be assembled inline inside
|
||||
:mod:`custom_components.nestquest.coordinator`'s
|
||||
``_async_update_data``. That assembly is HA-coupled only because its
|
||||
host is a :class:`~homeassistant.helpers.update_coordinator.DataUpdateCoordinator`;
|
||||
the assembly itself touches no HA surface.
|
||||
|
||||
This module holds the pure pieces — the snapshot dataclasses
|
||||
(:class:`QuestInstanceView`, :class:`ChildDaySnapshot`,
|
||||
:class:`NestQuestSnapshot`), the cycle-day helper (:func:`_cycle_day`),
|
||||
the panel payload shaper (:func:`instance_payload`), and the async
|
||||
:func:`build_snapshot` builder — so the HA coordinator and the API
|
||||
service's panel snapshot route share ONE implementation of both the
|
||||
snapshot assembly and the entity-facing attribute payload shape.
|
||||
Nothing here imports :mod:`homeassistant`; the builder takes the
|
||||
database, the settings object, and the HA-local ``now`` (a single
|
||||
clock read the caller already resolved, from which it derives
|
||||
``today``) and returns the snapshot, mirroring exactly what the
|
||||
coordinator used to do inline.
|
||||
|
||||
``settings`` is accepted because the API route hands in the entry's
|
||||
:class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the
|
||||
assembly reads nothing from it — and is retained so the builder's
|
||||
contract stays stable if a future panel feature derives scan caps or
|
||||
rollup behaviour from configuration. No speculative read is wired in.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .completion import derive_state
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import CompletionEventsDao, QuestInstancesDao
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import (
|
||||
MAX_PREVIEW_SCAN_DAYS,
|
||||
PresenceEngine,
|
||||
PresenceOverride,
|
||||
PresenceSchedule,
|
||||
)
|
||||
from .settings import NestQuestSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestInstanceView:
|
||||
"""One instance plus everything the entities render from it."""
|
||||
|
||||
instance_id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
title: str
|
||||
icon: str | None
|
||||
window: str
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
state: str # open | done | missed
|
||||
overdue: bool
|
||||
completed_at: str | None
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChildDaySnapshot:
|
||||
"""One active child's day: instances, presence, and rollup counts."""
|
||||
|
||||
child_id: int
|
||||
child_name: str
|
||||
instances: tuple[QuestInstanceView, ...]
|
||||
present: bool
|
||||
due_today: int
|
||||
completed_today: int
|
||||
remaining_today: int
|
||||
completion_pct: int
|
||||
#: For a child away today: the ISO date of their next present day
|
||||
#: (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||
#: from it). ``None`` when the child is present today or no
|
||||
#: present day exists within the presence engine's scan cap.
|
||||
next_present: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NestQuestSnapshot:
|
||||
"""The coordinator's whole-entity data payload."""
|
||||
|
||||
today_iso: str
|
||||
children: tuple[ChildDaySnapshot, ...]
|
||||
cycle_day: int
|
||||
|
||||
|
||||
def _cycle_day(
|
||||
schedules: dict[int, PresenceSchedule], children, today: datetime.date
|
||||
) -> int:
|
||||
"""Return today's 1-based day in the first scheduled child's cycle.
|
||||
|
||||
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
|
||||
the schedule's own anchor, never ISO week numbers or parity, so a
|
||||
53-week year cannot silently invert the cycle.
|
||||
"""
|
||||
for child in children:
|
||||
schedule = schedules.get(child.id)
|
||||
if schedule is None:
|
||||
continue
|
||||
days = (today - schedule.anchor_date).days
|
||||
cycle_length = schedule.cycle_length_weeks * 7
|
||||
return (days % cycle_length) + 1
|
||||
return 0
|
||||
|
||||
|
||||
def instance_payload(
|
||||
instances: Iterable[QuestInstanceView], *, include_missed: bool
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
|
||||
|
||||
The panel payload ``state`` is the documented ``open`` | ``completed``
|
||||
spelling, so the builder's derived ``done`` maps to ``completed`` here;
|
||||
``open`` passes through. ``missed`` instances are OMITTED from the
|
||||
entity-facing attribute payload (decision 5) unless ``include_missed``
|
||||
is set — the admin payload (D-009) keeps them with their ``missed``
|
||||
state. ``on_time`` passes the builder's ``was_on_time`` through
|
||||
verbatim (``True`` / ``False`` for a completed instance, ``None`` for
|
||||
an open one). This is the SINGLE shared implementation both the HA
|
||||
sensor layer and the API panel route read; it touches no Home
|
||||
Assistant surface.
|
||||
"""
|
||||
payload: list[dict[str, Any]] = []
|
||||
for view in instances:
|
||||
if view.state == "missed" and not include_missed:
|
||||
continue
|
||||
payload.append(
|
||||
{
|
||||
"id": view.instance_id,
|
||||
"definition_id": view.definition_id,
|
||||
"child_id": view.child_id,
|
||||
"title": view.title,
|
||||
"icon": view.icon,
|
||||
"window": view.window,
|
||||
"due_time": view.due_time,
|
||||
"state": (
|
||||
"completed" if view.state == "done" else view.state
|
||||
),
|
||||
"overdue": view.overdue,
|
||||
"completed_at": view.completed_at,
|
||||
"on_time": view.was_on_time,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def build_snapshot(
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
now: datetime.datetime,
|
||||
) -> NestQuestSnapshot:
|
||||
"""Build the household panel snapshot for ``today`` at the ``now`` instant.
|
||||
|
||||
Mirrors the assembly that lived in the coordinator's
|
||||
``_async_update_data``: active children in sort order, each child's
|
||||
today-instances with derived state (open / done / missed via
|
||||
:func:`~.completion.derive_state`) and the overdue flag, per-child
|
||||
presence (resolved through :class:`~.presence.PresenceEngine`), the
|
||||
per-child rollup counts, and the custody cycle day of the first
|
||||
scheduled active child. Missed instances are retained on the
|
||||
snapshot (the rollup counts and the panel's missed plate need them)
|
||||
but the entity-facing attribute payload omits them at the
|
||||
:func:`instance_payload` layer — this builder produces the shared
|
||||
shape both consumers read.
|
||||
|
||||
``now`` is the HA-local timezone-aware datetime the caller already
|
||||
resolved (a SINGLE clock read); the overdue check compares
|
||||
``now.time()`` against the instance's ``due_time`` for today's open
|
||||
instances. The builder performs NO internal clock read — it never
|
||||
calls :func:`datetime.datetime.now` — so the instant is pinned in
|
||||
tests. ``today`` is derived INSIDE the builder as ``now.date()``,
|
||||
so the calendar date that anchors the due-date and cycle-day
|
||||
arithmetic is always the SAME clock read as the wall-clock time;
|
||||
the two can never disagree. ``settings`` is accepted but currently
|
||||
unused (see the module docstring).
|
||||
"""
|
||||
today = now.date()
|
||||
today_iso = today.isoformat()
|
||||
|
||||
children = await ChildrenDao(database).list_active()
|
||||
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
overrides: dict[int, list[PresenceOverride]] = {}
|
||||
for child in children:
|
||||
schedule = await PresenceSchedulesDao(database).get_by_child(
|
||||
child.id
|
||||
)
|
||||
if schedule is not None:
|
||||
schedules[child.id] = PresenceSchedule.decode(
|
||||
child.id, schedule.anchor_date, schedule.pattern
|
||||
)
|
||||
window_end = today + datetime.timedelta(
|
||||
days=MAX_PREVIEW_SCAN_DAYS
|
||||
)
|
||||
window_end_iso = window_end.isoformat()
|
||||
ranged = await PresenceOverridesDao(
|
||||
database
|
||||
).list_by_child_and_range(child.id, today_iso, window_end_iso)
|
||||
if ranged:
|
||||
# The engine validates MODEL objects, not DAO records;
|
||||
# each record converts losslessly (the schema guarantees
|
||||
# end >= start and a real is_present).
|
||||
overrides[child.id] = [
|
||||
PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in ranged
|
||||
]
|
||||
engine = PresenceEngine(schedules, overrides)
|
||||
|
||||
instances_dao = QuestInstancesDao(database)
|
||||
events_dao = CompletionEventsDao(database)
|
||||
definitions_dao = QuestDefinitionsDao(database)
|
||||
|
||||
snapshots: list[ChildDaySnapshot] = []
|
||||
for child in children:
|
||||
instances = await instances_dao.list_by_date_range(
|
||||
child.id, today_iso, today_iso
|
||||
)
|
||||
views: list[QuestInstanceView] = []
|
||||
for instance in instances:
|
||||
latest = await events_dao.get_latest_for_instance(instance.id)
|
||||
definition = await definitions_dao.get(instance.definition_id)
|
||||
state = derive_state(instance, latest, today)
|
||||
overdue = False
|
||||
if state == "open" and instance.due_date == today_iso:
|
||||
if instance.due_time is not None:
|
||||
due_moment = datetime.datetime.strptime(
|
||||
instance.due_time, "%H:%M"
|
||||
).time()
|
||||
overdue = now.time() > due_moment
|
||||
views.append(
|
||||
QuestInstanceView(
|
||||
instance_id=instance.id,
|
||||
definition_id=instance.definition_id,
|
||||
child_id=instance.child_id,
|
||||
title=definition.title if definition else "Quest",
|
||||
icon=definition.icon if definition else None,
|
||||
window=instance.window,
|
||||
due_date=instance.due_date,
|
||||
due_time=instance.due_time,
|
||||
state=state,
|
||||
overdue=overdue,
|
||||
completed_at=(
|
||||
latest.occurred_at
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
was_on_time=(
|
||||
latest.was_on_time
|
||||
if latest is not None
|
||||
and latest.event_type == "completed"
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
due = len(views)
|
||||
completed = sum(1 for view in views if view.state == "done")
|
||||
remaining = due - completed
|
||||
child_present = engine.is_present(child.id, today)
|
||||
next_present: str | None = None
|
||||
if not child_present:
|
||||
try:
|
||||
next_present = engine.next_present_dates(
|
||||
child.id, today, 1
|
||||
)[0].isoformat()
|
||||
except ValueError:
|
||||
next_present = None
|
||||
snapshots.append(
|
||||
ChildDaySnapshot(
|
||||
child_id=child.id,
|
||||
child_name=child.display_name,
|
||||
instances=tuple(views),
|
||||
present=child_present,
|
||||
due_today=due,
|
||||
completed_today=completed,
|
||||
remaining_today=remaining,
|
||||
completion_pct=(
|
||||
round(completed * 100 / due) if due else 100
|
||||
),
|
||||
next_present=next_present,
|
||||
)
|
||||
)
|
||||
|
||||
return NestQuestSnapshot(
|
||||
today_iso=today_iso,
|
||||
children=tuple(snapshots),
|
||||
cycle_day=_cycle_day(schedules, children, today),
|
||||
)
|
||||
@@ -1,170 +0,0 @@
|
||||
"""The nightly missed-quest sweep, Home-Assistant-free (Feature 11).
|
||||
|
||||
This is the HA-free heart of the missed sweep, shared by the two
|
||||
planes: the Home Assistant integration's :mod:`~..sweep` shim (which
|
||||
fires the returned events on ``hass.bus``) and the API service (which
|
||||
publishes them on the SSE stream and can also trigger the run on
|
||||
demand). It owns the ONE sweep policy:
|
||||
|
||||
At the configured day-rollover time (or on an explicit trigger) this
|
||||
job finds every instance whose ``due_date`` is before the
|
||||
household-local date, that has NO completion event, and that falls
|
||||
after the persisted sweep watermark — and RETURNS one
|
||||
``nestquest_quest_missed`` event per instance with the documented
|
||||
payload. Like every core module, it never touches the Home Assistant
|
||||
event bus and never mutates an instance: ``missed`` is derived state,
|
||||
never an event_type (Feature 08's contract), so the sweep is read-only
|
||||
over the domain tables.
|
||||
|
||||
Idempotency is the watermark: after a run, ``nestquest_meta_state``
|
||||
records the household-local date the sweep ran, and the next run only
|
||||
sweeps instances due in ``[watermark, today)``. A second run the same
|
||||
night therefore fires nothing new, and a run after days of downtime
|
||||
sweeps exactly the accumulated window once — no per-instance journal,
|
||||
no re-announcing old misses. An instance swept as missed that is
|
||||
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
|
||||
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.
|
||||
|
||||
The caller supplies ``today`` as a plain :class:`datetime.date`: the
|
||||
household-local calendar date, read from ``hass.config.time_zone`` by
|
||||
the integration or from the API host's local clock by the API. This
|
||||
module never reads a timezone database or config of its own.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from .const import EVENT_QUEST_MISSED
|
||||
from .dao_meta import MetaStateDao
|
||||
from .dao_instances import QuestInstancesDao
|
||||
from .db import NestQuestDatabase
|
||||
# Intentional intra-package reuse of the shared payload builder: the
|
||||
# missed event's payload is the documented §3 instance payload, and
|
||||
# building it through the ONE builder keeps this sweep's payloads from
|
||||
# drifting from the completion/uncompleted builders' shape.
|
||||
from .events import _instance_payload
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: meta_state key holding the household-local date the sweep last ran.
|
||||
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.
|
||||
_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock:
|
||||
"""Return the sweep lock bound to ``database``'s running loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _SWEEP_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_SWEEP_LOCKS[key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
|
||||
#: explicit offset, second precision.
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
async def run_missed_sweep(
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
today: datetime.date,
|
||||
) -> list[tuple[str, dict]]:
|
||||
"""Build one missed event per unswept past-due open instance.
|
||||
|
||||
``today`` is the household-local calendar date the sweep runs for
|
||||
(the caller owns the clock: the integration reads it from
|
||||
``hass.config.time_zone``, the API from its host's local time).
|
||||
Returns the ``(event_type, payload)`` tuples to announce, EMPTY
|
||||
when the watermark already covers ``today`` (a same-night rerun) —
|
||||
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
|
||||
``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)
|
||||
async with _sweep_lock(database):
|
||||
watermark = await dao.get(SWEEP_WATERMARK_KEY)
|
||||
if watermark == today_iso:
|
||||
LOGGER.debug(
|
||||
"NestQuest missed sweep already ran for %s", today_iso
|
||||
)
|
||||
return []
|
||||
|
||||
instances_dao = QuestInstancesDao(database)
|
||||
rows = await instances_dao.list_missed_unswept(today_iso, watermark)
|
||||
now_stamp = datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
events: list[tuple[str, dict]] = []
|
||||
for instance in rows:
|
||||
events.append(
|
||||
(
|
||||
EVENT_QUEST_MISSED,
|
||||
await _instance_payload(
|
||||
database, instance.id, now_stamp
|
||||
),
|
||||
)
|
||||
)
|
||||
# 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 []
|
||||
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,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 (
|
||||
@@ -40,23 +39,14 @@ from .coordinator import (
|
||||
NestQuestCoordinator,
|
||||
QuestInstanceView,
|
||||
)
|
||||
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.
|
||||
@@ -73,6 +63,40 @@ def _state_within_limit(state: Any) -> Any:
|
||||
return state
|
||||
|
||||
|
||||
def _instance_payload(
|
||||
instances: Iterable[QuestInstanceView], *, include_missed: bool
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shape instance dicts per ENTITIES-AND-SERVICES.md §1.
|
||||
|
||||
``state`` maps the coordinator's derived ``done`` to the panel's
|
||||
``completed`` spelling; ``missed`` instances are omitted unless
|
||||
``include_missed`` (the admin payload, D-009, keeps them with
|
||||
their ``missed`` state).
|
||||
"""
|
||||
payload: list[dict[str, Any]] = []
|
||||
for view in instances:
|
||||
if view.state == "missed" and not include_missed:
|
||||
continue
|
||||
payload.append(
|
||||
{
|
||||
"id": view.instance_id,
|
||||
"definition_id": view.definition_id,
|
||||
"child_id": view.child_id,
|
||||
"title": view.title,
|
||||
"icon": view.icon,
|
||||
"window": view.window,
|
||||
"due_time": view.due_time,
|
||||
"state": (
|
||||
"completed" if view.state == "done" else view.state
|
||||
),
|
||||
"overdue": view.overdue,
|
||||
"completed_at": view.completed_at,
|
||||
"on_time": view.was_on_time,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
#: State reported by the next-quest sensor when the child owes nothing
|
||||
#: else today. A documented sentinel, never an empty string (an empty
|
||||
#: state reads as "sensor went unavailable" in HA).
|
||||
@@ -107,51 +131,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.
|
||||
|
||||
@@ -236,10 +215,10 @@ class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
|
||||
if child is None:
|
||||
return None
|
||||
return {
|
||||
"instances": instance_payload(
|
||||
"instances": _instance_payload(
|
||||
child.instances, include_missed=False
|
||||
),
|
||||
"admin_instances": instance_payload(
|
||||
"admin_instances": _instance_payload(
|
||||
child.instances, include_missed=True
|
||||
),
|
||||
"child_id": child.child_id,
|
||||
@@ -410,12 +389,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 +413,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 (and the
|
||||
presence DAOs). ``export_history_csv`` is registered with a schema but
|
||||
raises until Feature 13. ``regenerate`` is supplied by the caller so
|
||||
the Feature 07 handler stays the one in ``__init__``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .api_client import NestQuestApiError
|
||||
from . import children as children_layer
|
||||
from . import completion as completion_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 .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .events import fire_quest_completed, fire_quest_uncompleted
|
||||
from .materialize import regenerate_for_child
|
||||
from .permissions import permission_gate
|
||||
from .presence import PresenceSchedule
|
||||
from .recurrence import ScheduleRule
|
||||
from .service_policy import SERVICE_POLICY
|
||||
|
||||
FindRuntime = Callable[[HomeAssistant], Any]
|
||||
ServiceHandler = Callable[[Any], Awaitable[None]]
|
||||
|
||||
ACTOR_PANEL = "panel"
|
||||
ACTOR_USER = "user"
|
||||
MANAGE_CHILD_CREATE = "create"
|
||||
MANAGE_CHILD_EDIT = "edit"
|
||||
MANAGE_CHILD_SET_ACTIVE = "set_active"
|
||||
MANAGE_CHILD_REORDER = "reorder"
|
||||
|
||||
|
||||
def _strict_int(value: object) -> int:
|
||||
@@ -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,243 @@ 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
|
||||
schedule = PresenceSchedule(
|
||||
data["child_id"],
|
||||
data["cycle_length_weeks"],
|
||||
data["anchor_date"],
|
||||
data["pattern"],
|
||||
)
|
||||
await PresenceSchedulesDao(runtime.database).upsert_by_child(
|
||||
schedule.child_id,
|
||||
schedule.cycle_length_weeks,
|
||||
schedule.anchor_date.isoformat(),
|
||||
schedule.encode(),
|
||||
)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
schedule.child_id,
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _create_presence_override(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
await PresenceOverridesDao(runtime.database).create(
|
||||
data["child_id"],
|
||||
data["start_date"],
|
||||
data["end_date"],
|
||||
data["is_present"],
|
||||
note=data.get("note"),
|
||||
)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
data["child_id"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def _delete_presence_override(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
dao = PresenceOverridesDao(runtime.database)
|
||||
override_id = call.data["override_id"]
|
||||
override = await dao.get(override_id)
|
||||
if override is None:
|
||||
raise HomeAssistantError(
|
||||
f"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)
|
||||
f"override_id: presence override {override_id} does not exist"
|
||||
)
|
||||
await dao.delete(override_id)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
override.child_id,
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
async def _export_history_csv(_call: Any) -> None:
|
||||
raise HomeAssistantError(
|
||||
"CSV history export is not implemented until Feature 13"
|
||||
)
|
||||
|
||||
|
||||
def _manage_child(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
database = _require_runtime(hass, find_runtime).database
|
||||
data = call.data
|
||||
action = data["action"]
|
||||
if action == MANAGE_CHILD_CREATE:
|
||||
display_name = data.get("display_name")
|
||||
if display_name is None:
|
||||
raise HomeAssistantError(
|
||||
"display_name is required when action is create"
|
||||
)
|
||||
kwargs: dict[str, Any] = {}
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.create_child(
|
||||
database, display_name, **kwargs
|
||||
)
|
||||
return
|
||||
if action == MANAGE_CHILD_EDIT:
|
||||
child_id = data.get("child_id")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is edit"
|
||||
)
|
||||
kwargs = {}
|
||||
if "display_name" in data:
|
||||
kwargs["display_name"] = data["display_name"]
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.edit_child(database, child_id, **kwargs)
|
||||
return
|
||||
if action == MANAGE_CHILD_SET_ACTIVE:
|
||||
child_id = data.get("child_id")
|
||||
is_active = data.get("is_active")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is set_active"
|
||||
)
|
||||
if is_active is None:
|
||||
raise HomeAssistantError(
|
||||
"is_active is required when action is set_active"
|
||||
)
|
||||
await children_layer.set_child_active(
|
||||
database, child_id, is_active
|
||||
)
|
||||
return
|
||||
ordered_ids = data.get("ordered_ids")
|
||||
if ordered_ids is None:
|
||||
raise HomeAssistantError(
|
||||
"ordered_ids is required when action is reorder"
|
||||
)
|
||||
await children_layer.reorder_children(database, ordered_ids)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
@@ -166,25 +546,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,132 @@
|
||||
"""The nightly missed-quest sweep (Feature 11).
|
||||
|
||||
At the configured day-rollover time (the same HA-local listener that
|
||||
re-materializes the rolling horizon) this job finds every instance
|
||||
whose ``due_date`` is before the HA-local date, that has NO completion
|
||||
event, and that falls after the persisted sweep watermark — and fires
|
||||
ONE ``nestquest_quest_missed`` event per instance with the documented
|
||||
payload. The sweep is read-only over the domain tables: it never
|
||||
mutates an instance and never writes a completion event (``missed``
|
||||
is derived state, never an event_type — Feature 08's contract).
|
||||
|
||||
Idempotency is the 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 therefore fires nothing new, and a run after days of
|
||||
downtime sweeps exactly the accumulated window once — no
|
||||
per-instance journal, no re-announcing old misses. An instance swept
|
||||
as missed that is 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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .const import EVENT_QUEST_MISSED
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import QuestInstancesDao
|
||||
from .dao_meta import MetaStateDao
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: meta_state key holding the HA-local date the sweep last ran.
|
||||
SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date"
|
||||
|
||||
#: One asyncio.Lock per (connection wrapper, running loop), mirroring
|
||||
#: the migration runner's pattern: the whole watermark read → query →
|
||||
#: fire → 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] = {}
|
||||
|
||||
|
||||
def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock:
|
||||
"""Return the sweep lock bound to ``database``'s running loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _SWEEP_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_SWEEP_LOCKS[key] = lock
|
||||
return lock
|
||||
|
||||
#: UTC ISO-8601 timestamp policy shared with the DAO layers.
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
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 whole watermark read → query → fire → 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.
|
||||
"""
|
||||
today_date = (
|
||||
today
|
||||
if today is not None
|
||||
else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
|
||||
)
|
||||
today_iso = today_date.isoformat()
|
||||
dao = MetaStateDao(database)
|
||||
async with _sweep_lock(database):
|
||||
watermark = await dao.get(SWEEP_WATERMARK_KEY)
|
||||
if watermark == today_iso:
|
||||
LOGGER.debug(
|
||||
"NestQuest missed sweep already ran for %s", today_iso
|
||||
)
|
||||
return 0
|
||||
|
||||
instances_dao = QuestInstancesDao(database)
|
||||
rows = await instances_dao.list_missed_unswept(today_iso, watermark)
|
||||
children_dao = ChildrenDao(database)
|
||||
definitions_dao = QuestDefinitionsDao(database)
|
||||
now_stamp = _now_stamp()
|
||||
fired = 0
|
||||
for instance in rows:
|
||||
child = await children_dao.get(instance.child_id)
|
||||
definition = await definitions_dao.get(instance.definition_id)
|
||||
hass.bus.async_fire(
|
||||
EVENT_QUEST_MISSED,
|
||||
{
|
||||
"child_id": instance.child_id,
|
||||
"child_name": child.display_name if child else None,
|
||||
"instance_id": instance.id,
|
||||
"quest_title": definition.title if definition else None,
|
||||
"window": instance.window,
|
||||
"due_date": instance.due_date,
|
||||
"due_time": instance.due_time,
|
||||
"occurred_at": now_stamp,
|
||||
},
|
||||
)
|
||||
fired += 1
|
||||
await dao.set(SWEEP_WATERMARK_KEY, today_iso)
|
||||
LOGGER.info(
|
||||
"NestQuest missed sweep for %s fired %d event(s)", today_iso, 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
@@ -0,0 +1,11 @@
|
||||
---
|
||||
description: Show the NestQuest development cycle status
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle status`
|
||||
|
||||
Report whether the controller is active and its last tick state.
|
||||
Do not suggest enabling automatic startup.
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
description: Run the NestQuest orchestrator now in this OpenCode terminal
|
||||
agent: terminal-orchestrator
|
||||
description: Start the NestQuest development cycle on this host
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
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.
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle start`
|
||||
|
||||
Report its status. If it says an interactive NestQuest OpenCode session is
|
||||
open, tell me to close this session; the background controller will begin
|
||||
afterward. Do not launch a developer agent or a controller tick in this chat.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Stop the NestQuest development cycle on this host
|
||||
agent: cycle-control
|
||||
---
|
||||
|
||||
Run exactly this one shell command:
|
||||
|
||||
`/home/overseer/.local/share/nestquest-controller/scripts/nq-cycle stop`
|
||||
|
||||
Report the service status. Do not stop any other OpenCode session or task.
|
||||
@@ -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);
|
||||
@@ -22,8 +22,9 @@ class CyclePromptTests(unittest.TestCase):
|
||||
"RELEASE_BRANCH": "production",
|
||||
"CONTROL_HOME": "/opt/examplequest/controller",
|
||||
"SERVICE_NAME": "examplequest-controller.service",
|
||||
"PROJECT_ROOT": "/srv/examplequest",
|
||||
"TERMINAL_LAUNCHER": "eq-orchestrate",
|
||||
"START_COMMAND": "/start-examplequest-development",
|
||||
"STOP_COMMAND": "/stop-examplequest-development",
|
||||
"STATUS_COMMAND": "/examplequest-development-status",
|
||||
"FEATURE_BRANCH_EXAMPLE": "feature/EQ-17",
|
||||
})
|
||||
output = RENDER(TEMPLATE, other)
|
||||
@@ -43,44 +44,6 @@ class CyclePromptTests(unittest.TestCase):
|
||||
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,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
-12
@@ -1,28 +1,17 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.6.0"
|
||||
version = "0.5.0"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[dependency-groups]
|
||||
# fastapi and uvicorn live in the DEV group only so the monorepo's test
|
||||
# venv can exercise the API app (tests under tests/test_api_health.py).
|
||||
# They are NOT runtime dependencies of the NestQuest HA integration.
|
||||
# When the API deployable is packaged (Feature 17), its runtime
|
||||
# dependencies — fastapi, uvicorn, and any others — MUST be declared in
|
||||
# that deployable's own dependency manifest, not here.
|
||||
dev = [
|
||||
"fastapi>=0.115",
|
||||
"httpx>=0.28",
|
||||
"jinja2>=3",
|
||||
"pyjwt[crypto]>=2.9",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pyyaml>=6",
|
||||
"unidecode>=1.4.0",
|
||||
"uvicorn>=0.30",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Manually control the durable NestQuest development cycle from OpenCode.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: nq-cycle start|stop|status" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
unit=nestquest-controller.service
|
||||
controller=/home/overseer/.local/share/nestquest-controller/scripts/nq-controller
|
||||
|
||||
show_status() {
|
||||
systemctl --user show "$unit" -p ActiveState -p SubState -p UnitFileState
|
||||
if [[ -f /home/overseer/.local/state/nestquest-controller/last-run.json ]]; then
|
||||
python3 -c 'import json,pathlib; p=pathlib.Path.home()/".local/state/nestquest-controller/last-run.json"; d=json.loads(p.read_text()); print("LastTickState="+str(d.get("state","unknown"))); print("LastTickTime="+str(d.get("time","unknown")))'
|
||||
fi
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
systemctl --user start "$unit"
|
||||
show_status
|
||||
if session_status=$("$controller" --check-interactive); then
|
||||
echo "$session_status"
|
||||
if [[ "$session_status" == "interactive NestQuest session open" ]]; then
|
||||
echo "Close the interactive NestQuest OpenCode session to let the controller run its first tick."
|
||||
fi
|
||||
else
|
||||
echo "The controller will wait for OpenCode session detection to recover."
|
||||
fi
|
||||
;;
|
||||
stop)
|
||||
systemctl --user stop "$unit"
|
||||
show_status
|
||||
;;
|
||||
status)
|
||||
show_status
|
||||
;;
|
||||
*)
|
||||
echo "usage: nq-cycle start|stop|status" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
@@ -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"
|
||||
@@ -150,15 +150,6 @@ Possible scopes:
|
||||
- `PROJECT_QUEUE`
|
||||
- `RELEASE_PREPARATION`
|
||||
|
||||
Also set `RELEASE_PREPARATION_POLICY` at startup:
|
||||
|
||||
- `AUTO` by default.
|
||||
- `EXCLUDED` only when the user's launch request explicitly says not to prepare
|
||||
a release or `{{INTEGRATION_BRANCH}}`-to-`{{RELEASE_BRANCH}}` promotion.
|
||||
|
||||
Record both `MISSION_SCOPE` and `RELEASE_PREPARATION_POLICY` in durable run
|
||||
state. Do not infer exclusion merely from `SINGLE_TASK` or `SINGLE_FEATURE`.
|
||||
|
||||
If the user explicitly names one task or feature, respect that scope.
|
||||
|
||||
If the user launches the orchestrator for general/autonomous {{PROJECT_NAME}} development without limiting it to one feature, use:
|
||||
@@ -180,7 +171,6 @@ Maintain a durable orchestration checkpoint using {{WORK_TRACKER}} wherever its
|
||||
Track at minimum:
|
||||
|
||||
- `MISSION_SCOPE`
|
||||
- `RELEASE_PREPARATION_POLICY`
|
||||
- Current feature
|
||||
- Current controller phase
|
||||
- Active development agents
|
||||
@@ -195,9 +185,6 @@ Track at minimum:
|
||||
- Blocked items
|
||||
- Tests/checks status
|
||||
- Last verified repository state
|
||||
- Active promotion branch and pull request
|
||||
- Triggering feature and exact `{{INTEGRATION_BRANCH}}` commit for each automatic promotion
|
||||
- Completed features pending the next promotion
|
||||
- `NEXT_ACTION`
|
||||
|
||||
After every material state transition:
|
||||
@@ -267,8 +254,8 @@ Choose the highest-priority actionable transition in this order:
|
||||
8. Remediate feature-level review findings.
|
||||
9. Obtain feature approval.
|
||||
10. Merge an approved feature into `{{INTEGRATION_BRANCH}}`.
|
||||
11. Begin an automatically triggered, requested, or scheduled release when appropriate. The automatic post-feature trigger is in scope for both `SINGLE_FEATURE` and `PROJECT_QUEUE` unless `RELEASE_PREPARATION_POLICY = EXCLUDED`.
|
||||
12. Advance to the next eligible feature when mission scope permits.
|
||||
11. Advance to the next eligible feature when mission scope permits.
|
||||
12. Begin a requested/scheduled release when appropriate.
|
||||
13. Continue unrelated work around locally blocked items.
|
||||
14. Escalate only when the defined escalation rules require it.
|
||||
|
||||
@@ -601,10 +588,9 @@ Immediately transition to `FEATURE_INTEGRATION`.
|
||||
|
||||
A merged feature is a transition.
|
||||
|
||||
It is terminal only when `MISSION_SCOPE = SINGLE_FEATURE` and no authorized
|
||||
post-feature release transition is pending. Preparing the automatic promotion
|
||||
for that completed feature is part of `SINGLE_FEATURE` scope when
|
||||
`RELEASE_PREPARATION_POLICY = AUTO`.
|
||||
It is terminal ONLY when:
|
||||
|
||||
`MISSION_SCOPE = SINGLE_FEATURE`
|
||||
|
||||
Otherwise select the next eligible work.
|
||||
|
||||
@@ -651,19 +637,13 @@ The orchestrator itself does not perform the merge.
|
||||
When `MISSION_SCOPE = PROJECT_QUEUE` and a feature is successfully merged into `{{INTEGRATION_BRANCH}}`:
|
||||
|
||||
1. Reconcile {{WORK_TRACKER}}.
|
||||
2. Evaluate the automatic, requested, and scheduled release triggers.
|
||||
2. Check for a requested/scheduled release.
|
||||
3. Identify remaining incomplete features.
|
||||
4. Determine which features are eligible based on dependencies and priority.
|
||||
5. Select the next eligible feature.
|
||||
6. Initialize it.
|
||||
7. Continue the development loop.
|
||||
|
||||
If an automatic promotion becomes `RELEASE_READY` while another eligible
|
||||
feature remains, keep that promotion waiting for {{RELEASE_OWNER_NAME}} and
|
||||
continue the project queue. A ready promotion is a local checkpoint in this
|
||||
case, not a global terminal condition. Features completed while it remains open
|
||||
are recorded as pending for the next promotion under Section 18.
|
||||
|
||||
Do not return to the user simply because one feature completed.
|
||||
|
||||
If several features are available, choose using:
|
||||
@@ -678,52 +658,27 @@ If several features are available, choose using:
|
||||
|
||||
# 18. RELEASE RULES
|
||||
|
||||
Completed features accumulate in `{{INTEGRATION_BRANCH}}`. With no active
|
||||
promotion, each verified feature completion starts release preparation. While
|
||||
one promotion is active, later completed features accumulate for the next
|
||||
promotion.
|
||||
Completed features accumulate in `{{INTEGRATION_BRANCH}}`.
|
||||
|
||||
Prepare a release when any of these conditions is true:
|
||||
Prepare a release only when:
|
||||
|
||||
- {{RELEASE_OWNER_NAME}} requests it; OR
|
||||
- {{WORK_TRACKER}} schedules it; OR
|
||||
- `RELEASE_PREPARATION_POLICY = AUTO`, and a feature becomes task-complete and its reviewed merge into `{{INTEGRATION_BRANCH}}` is verified.
|
||||
|
||||
For the automatic trigger, a feature is task-complete only when:
|
||||
|
||||
- Every required feature task is `done`, or deliberately `canceled` with recorded evidence.
|
||||
- No task review, remediation, approval, or merge remains pending.
|
||||
- The feature acceptance criteria and integrated checks pass.
|
||||
- Independent approval applies to the exact feature head.
|
||||
- The approver's merge of that head into `{{INTEGRATION_BRANCH}}` is verified.
|
||||
- {{WORK_TRACKER}} reflects the completed feature and exact repository references.
|
||||
|
||||
Task completion inside an unmerged feature branch does not trigger a release.
|
||||
The verified feature-to-`{{INTEGRATION_BRANCH}}` merge is the triggering transition.
|
||||
|
||||
Before creating a release branch or pull request:
|
||||
|
||||
1. Verify this session is the sole active orchestrator for the project. `{{TERMINAL_LAUNCHER}}` enforces that exclusivity by holding the shared controller lock for the full foreground session. If that lock is not held by this session, do not create a release branch or pull request; escalate instead.
|
||||
2. Reconcile open release records, release branches, and promotion pull requests.
|
||||
3. Do not create a duplicate promotion for an already-covered `{{INTEGRATION_BRANCH}}` commit.
|
||||
4. Allow only one active promotion pull request to `{{RELEASE_BRANCH}}`. If an earlier promotion is still open and does not contain the newly completed feature, record the feature as pending for the next automatic promotion. Resume it after the active promotion is merged or closed; if it was closed without merge, recalculate the next promotion from the current validated `{{INTEGRATION_BRANCH}}` head.
|
||||
5. Record the triggering feature and exact `{{INTEGRATION_BRANCH}}` commit so the automatic trigger runs once for that merge.
|
||||
- {{WORK_TRACKER}} schedules it.
|
||||
|
||||
Release process:
|
||||
|
||||
1. Enforce every pre-creation requirement above, including controller exclusivity, reconciliation, duplicate and active-promotion checks, and trigger recording.
|
||||
2. Reconcile release scope.
|
||||
3. Confirm included features are complete.
|
||||
4. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
|
||||
5. Run complete release checks.
|
||||
6. Open release PR → `{{RELEASE_BRANCH}}`.
|
||||
7. Obtain fresh independent {{REVIEWER_VENDOR}} review.
|
||||
8. Remediate findings through development agents.
|
||||
9. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
|
||||
10. Revalidate.
|
||||
11. Assign final release PR to `{{HOST_ACCOUNT}}`.
|
||||
12. Update {{WORK_TRACKER}}.
|
||||
13. Stop before {{RELEASE_BRANCH}} merge.
|
||||
1. Reconcile release scope.
|
||||
2. Confirm included features are complete.
|
||||
3. Create release branch from exact validated `{{INTEGRATION_BRANCH}}`.
|
||||
4. Run complete release checks.
|
||||
5. Open release PR → `{{RELEASE_BRANCH}}`.
|
||||
6. Obtain fresh independent {{REVIEWER_VENDOR}} review.
|
||||
7. Remediate findings through development agents.
|
||||
8. Incorporate every release correction back into `{{INTEGRATION_BRANCH}}`.
|
||||
9. Revalidate.
|
||||
10. Assign final release PR to `{{HOST_ACCOUNT}}`.
|
||||
11. Update {{WORK_TRACKER}}.
|
||||
12. Stop before {{RELEASE_BRANCH}} merge.
|
||||
|
||||
Only {{RELEASE_OWNER_NAME}} may merge into `{{RELEASE_BRANCH}}`.
|
||||
|
||||
@@ -769,23 +724,13 @@ For `PROJECT_QUEUE`, this additionally requires:
|
||||
|
||||
- No eligible incomplete feature remains.
|
||||
- No task/review/remediation/merge is pending.
|
||||
- No release preparation, release review, or release remediation is pending.
|
||||
- Every in-scope automatic trigger is either incorporated into the active
|
||||
`RELEASE_READY` promotion or durably recorded for the next promotion.
|
||||
- No development agent is active.
|
||||
- No approver is active.
|
||||
|
||||
An active promotion that has not reached `RELEASE_READY` makes
|
||||
`MISSION_COMPLETE` false.
|
||||
|
||||
## `RELEASE_READY`
|
||||
|
||||
Release PR has passed required gates and is assigned to `{{HOST_ACCOUNT}}`, awaiting {{RELEASE_OWNER_NAME}}'s {{RELEASE_BRANCH}} merge.
|
||||
|
||||
`RELEASE_READY` is globally terminal only when no other in-scope engineering
|
||||
action is executable. In `PROJECT_QUEUE`, if another eligible incomplete
|
||||
feature remains, continue that work while the ready promotion waits.
|
||||
|
||||
## `GLOBAL_ESCALATION`
|
||||
|
||||
All remaining in-scope work is blocked by decisions/access that require {{RELEASE_OWNER_NAME}}.
|
||||
@@ -833,12 +778,9 @@ Output a machine-readable continuation checkpoint:
|
||||
```text
|
||||
ORCHESTRATOR_STATE: CONTINUE
|
||||
MISSION_SCOPE:
|
||||
RELEASE_PREPARATION_POLICY:
|
||||
CONTROLLER_PHASE:
|
||||
CURRENT_FEATURE:
|
||||
ACTIVE_TASKS:
|
||||
ACTIVE_PROMOTION:
|
||||
PENDING_PROMOTION_FEATURES:
|
||||
BLOCKED_ITEMS:
|
||||
LAST_VERIFIED_STATE:
|
||||
NEXT_ACTION:
|
||||
@@ -980,37 +922,44 @@ You are the controller of a persistent development pipeline. Keep the pipeline m
|
||||
|
||||
---
|
||||
|
||||
# 27. FOREGROUND TERMINAL RUNTIME
|
||||
# 27. HOST CONTROLLER RUNTIME
|
||||
|
||||
This section is the execution contract for a foreground OpenCode
|
||||
orchestrator. It replaces the service runtime section in the rendered prompt
|
||||
and takes precedence over any service-specific runtime instructions later
|
||||
read from {{CONTEXT_STORE}}.
|
||||
This section is the host execution contract for OpenCode {{OPENCODE_VERSION}} and takes
|
||||
precedence over Section 21 when deciding whether a single model invocation may
|
||||
end. The persistent user service runs one fresh, headless orchestrator turn per
|
||||
controller tick. Section 21 governs mission completion, not the lifetime of
|
||||
one OpenCode turn.
|
||||
|
||||
The user explicitly started this run with `{{TERMINAL_LAUNCHER}}`. Begin
|
||||
work immediately in `{{PROJECT_ROOT}}`; do not start, wait for, or delegate
|
||||
control to `{{SERVICE_NAME}}`. This single OpenCode session owns the
|
||||
orchestration loop until a global terminal condition, an explicit user stop,
|
||||
or a host-forced yield.
|
||||
The user starts this project's cycle explicitly from OpenCode with
|
||||
`{{START_COMMAND}}`. `{{STOP_COMMAND}}` stops it, and
|
||||
`{{STATUS_COMMAND}}` reports its state. The `{{SERVICE_NAME}}` user unit is
|
||||
not enabled at login. Reading this prompt in an ordinary interactive session
|
||||
does not authorize starting the background service. The service waits for the
|
||||
interactive project session to close before its first tick.
|
||||
|
||||
Execute `CONTROLLER_TICK` repeatedly inside this foreground session. Do not
|
||||
end a turn merely to emit `ORCHESTRATOR_STATE: CONTINUE`, and do not treat
|
||||
one task, review, pull request, or feature merge as completion. After every
|
||||
child result or external state change, reconcile {{CONTEXT_STORE}},
|
||||
{{WORK_TRACKER}}, Git, pull requests, and checks, then perform the next
|
||||
eligible action.
|
||||
At the end of EVERY orchestrator invocation, emit exactly one final line:
|
||||
`ORCHESTRATOR_STATE: CONTINUE`, `ORCHESTRATOR_STATE: QUIESCENT`, or
|
||||
`ORCHESTRATOR_STATE: BLOCKED`. Use CONTINUE whenever eligible or in-flight
|
||||
work remains and state the next concrete action immediately above that line.
|
||||
The service starts a fresh invocation after reconciling {{CONTEXT_STORE}}, {{WORK_TRACKER}}, Git,
|
||||
pull requests, and checks. Use QUIESCENT only after verifying that no eligible
|
||||
or in-flight work remains; the service polls again. Use BLOCKED only for a
|
||||
specific required human decision or access failure; name the missing action.
|
||||
A blocked task does not block other eligible work. If {{CONTEXT_STORE}} is unavailable,
|
||||
do not dispatch or merge, and return BLOCKED.
|
||||
|
||||
The installed role launchers live at `{{CONTROL_HOME}}/scripts/`. Use
|
||||
`{{ROLE_LAUNCHER}}` for developer and approver sessions. Use
|
||||
`{{REVIEW_LAUNCHER}}` with WORKTREE, BASE_REF, HEAD_REF,
|
||||
REVIEW_PROMPT_FILE, PR_NUMBER, OUTPUT_FILE for a fresh read-only
|
||||
{{REVIEWER_DISPLAY_NAME}} review. Review output belongs under
|
||||
`{{SCRATCH_DIR}}`, and exact-head approval must be posted by
|
||||
`{{REVIEW_ACCOUNT}}`. The feature-to-{{INTEGRATION_BRANCH}} approver must
|
||||
use `{{MERGE_BROKER}}`; the orchestrator and developer never perform that
|
||||
merge.
|
||||
The installed launchers live at
|
||||
`{{CONTROL_HOME}}/scripts/`. Use `{{ROLE_LAUNCHER}}`
|
||||
to start developer or approver OpenCode sessions. Use `{{REVIEW_LAUNCHER}}` with
|
||||
WORKTREE, BASE_REF, HEAD_REF, REVIEW_PROMPT_FILE, PR_NUMBER, OUTPUT_FILE to
|
||||
run a fresh read-only {{REVIEWER_DISPLAY_NAME}} review at high effort. Its output file
|
||||
must be under `{{SCRATCH_DIR}}`; it posts a {{CODE_HOST}} review as `{{REVIEW_ACCOUNT}}`
|
||||
only after approving the exact current head. The feature-to-{{INTEGRATION_BRANCH}} approver uses
|
||||
`{{MERGE_BROKER}}` and must verify that same exact-head {{CODE_HOST}} approval.
|
||||
No controller or developer session performs the feature-to-{{INTEGRATION_BRANCH}} merge.
|
||||
|
||||
If the host forces a yield before a global terminal condition, return the
|
||||
machine-readable checkpoint from Section 22. The user can resume the same
|
||||
OpenCode session from the terminal. A yield is a checkpoint, not a claim that
|
||||
the project queue is complete.
|
||||
The service holds one controller lock and waits while an interactive
|
||||
{{PROJECT_NAME}} OpenCode session is open. Each model turn has a {{TICK_TIMEOUT_LABEL}} host
|
||||
timeout. After an error or timeout, reconcile external state before retrying;
|
||||
three consecutive failed turns stop the service for inspection. A successful
|
||||
feature merge is a transition back into scheduling, not a terminal condition.
|
||||
|
||||
+5
-13
@@ -16,17 +16,9 @@ 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.
|
||||
systems differ.
|
||||
|
||||
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.
|
||||
Publish the reviewed **rendered** prompt into that project's context store
|
||||
with its version check. Give the project its own agent definitions, service
|
||||
unit, broker, and OpenCode start/stop commands. The template itself does not
|
||||
start a controller or register another project.
|
||||
|
||||
@@ -21,11 +21,12 @@
|
||||
"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",
|
||||
"OPENCODE_VERSION": "1.18.31",
|
||||
"CONTROL_HOME": "/home/overseer/.local/share/nestquest-controller",
|
||||
"SERVICE_NAME": "nestquest-controller.service",
|
||||
"TERMINAL_LAUNCHER": "nq-orchestrate",
|
||||
"START_COMMAND": "/start-nestquest-development",
|
||||
"STOP_COMMAND": "/stop-nestquest-development",
|
||||
"STATUS_COMMAND": "/nestquest-development-status",
|
||||
"ROLE_LAUNCHER": "nq-agent",
|
||||
"REVIEW_LAUNCHER": "nq-review",
|
||||
"MERGE_BROKER": "nq-approve-merge",
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Shared admin-plane JWT test harness: local keys, stubbed JWKS.
|
||||
|
||||
The admin-plane API test modules share ONE harness instead of
|
||||
duplicating it: the auth contract tests (:mod:`tests.test_api_admin_auth`)
|
||||
and the children route tests (:mod:`tests.test_api_admin_children`) both
|
||||
drive the ASGI app with httpx using a locally generated RSA key pair and
|
||||
a stubbed JWKS fetcher — NO network access:
|
||||
|
||||
- ``ADMIN_KEY`` signs the test tokens; :func:`jwks_for` builds the JWKS
|
||||
document publishing its public half, so genuinely signed tokens
|
||||
verify, and tokens signed with the second pair (``OTHER_KEY``) fail
|
||||
the signature check for real.
|
||||
- :func:`make_token` mints one RS256 JWT the way Authentik would (its
|
||||
claims, our key), defaulting ``groups`` to the admin group a real
|
||||
admin's token carries.
|
||||
- :class:`StubbedFetch` is the counting JWKS fetcher stub.
|
||||
- :class:`AdminRunner` runs the app's lifespan around an httpx
|
||||
AsyncClient (httpx's ASGI transport does not run the lifespan, so it
|
||||
is driven explicitly and shut down in ``__aexit__``'s finally block
|
||||
even on failure) with the app's JWKS cache swapped for one built
|
||||
around the stub fetcher.
|
||||
|
||||
The harness carries no tests itself; the ``admin_client`` fixture in
|
||||
``tests/conftest.py`` hands its runner to the test modules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
from api.app import create_app
|
||||
from api.auth import ADMIN_GROUP, JwksCache
|
||||
from api.config import ApiConfig
|
||||
|
||||
#: The configured OIDC settings the test apps run with (values a real
|
||||
#: deployment would get from the NESTQUEST_OIDC_* environment vars).
|
||||
ISSUER = "https://authentik.example.com/application/o/nestquest/"
|
||||
AUDIENCE = "nestquest-api"
|
||||
JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/"
|
||||
|
||||
#: The ``kid`` the test tokens carry, matching the JWKS entry.
|
||||
KID = "admin-test-key-1"
|
||||
|
||||
#: The subject a valid token authenticates as.
|
||||
SUBJECT = "admin-user-1"
|
||||
|
||||
|
||||
def _generate_key() -> rsa.RSAPrivateKey:
|
||||
"""Generate one local RSA key pair (no network, no fixtures)."""
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens
|
||||
#: signed with it verify; the second pair below proves they don't when
|
||||
#: signed otherwise.
|
||||
ADMIN_KEY = _generate_key()
|
||||
OTHER_KEY = _generate_key()
|
||||
|
||||
|
||||
def _b64url_uint(value: int) -> str:
|
||||
"""Base64url-encode a non-negative integer (no padding), per JWK."""
|
||||
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]:
|
||||
"""Build a JWKS document publishing ``private_key``'s public half."""
|
||||
numbers = private_key.public_key().public_numbers()
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": kid,
|
||||
"n": _b64url_uint(numbers.n),
|
||||
"e": _b64url_uint(numbers.e),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class StubbedFetch:
|
||||
"""A JWKS fetcher stub: returns a fixed document, counts calls."""
|
||||
|
||||
def __init__(self, document: dict[str, object]) -> None:
|
||||
self.document = document
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
return self.document
|
||||
|
||||
|
||||
def make_token(
|
||||
signing_key: rsa.RSAPrivateKey = ADMIN_KEY,
|
||||
*,
|
||||
kid: str | None = KID,
|
||||
iss: str = ISSUER,
|
||||
aud: object = AUDIENCE,
|
||||
sub: str = SUBJECT,
|
||||
expires_in: int | None = 300,
|
||||
nbf_in: int | None = -1,
|
||||
groups: object = (ADMIN_GROUP,),
|
||||
) -> str:
|
||||
"""Mint one RS256 JWT the way Authentik would (its claims, our key).
|
||||
|
||||
``groups`` defaults to the admin group (what a real admin user's
|
||||
token carries); tests pass a different value — or ``None`` for NO
|
||||
groups claim at all — to prove the group gate rejects it.
|
||||
"""
|
||||
now = int(time.time())
|
||||
claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now}
|
||||
if groups is not None:
|
||||
claims["groups"] = (
|
||||
list(groups) if isinstance(groups, (list, tuple)) else groups
|
||||
)
|
||||
if nbf_in is not None:
|
||||
claims["nbf"] = now + nbf_in
|
||||
if expires_in is not None:
|
||||
claims["exp"] = now + expires_in
|
||||
headers: dict[str, str] = {}
|
||||
if kid is not None:
|
||||
headers["kid"] = kid
|
||||
return jwt.encode(
|
||||
claims, signing_key, algorithm="RS256", headers=headers
|
||||
)
|
||||
|
||||
|
||||
class AdminRunner:
|
||||
"""Run the app's lifespan around an httpx AsyncClient.
|
||||
|
||||
Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI
|
||||
transport does not run the lifespan, so it is driven explicitly and
|
||||
shut down in ``__aexit__``'s finally block even on failure.
|
||||
|
||||
The app's JWKS cache is replaced (before the lifespan runs) with
|
||||
one built around a counting stub fetcher, so verification goes
|
||||
through the REAL cache logic but never the network.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str,
|
||||
jwks_document: dict[str, object],
|
||||
*,
|
||||
ttl_seconds: float | None = None,
|
||||
backoff_seconds: float | None = None,
|
||||
kid_refetch_seconds: float | None = None,
|
||||
) -> None:
|
||||
self.app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token="admin-test-panel-token",
|
||||
oidc_issuer=ISSUER,
|
||||
oidc_audience=AUDIENCE,
|
||||
oidc_jwks_url=JWKS_URL,
|
||||
)
|
||||
)
|
||||
self.fetch = StubbedFetch(jwks_document)
|
||||
cache_kwargs: dict[str, float] = {}
|
||||
if ttl_seconds is not None:
|
||||
cache_kwargs["ttl_seconds"] = ttl_seconds
|
||||
if backoff_seconds is not None:
|
||||
cache_kwargs["backoff_seconds"] = backoff_seconds
|
||||
if kid_refetch_seconds is not None:
|
||||
cache_kwargs["kid_refetch_seconds"] = kid_refetch_seconds
|
||||
self.app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=self.fetch, **cache_kwargs
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> httpx.AsyncClient:
|
||||
self._lifespan = self.app.router.lifespan_context(self.app)
|
||||
await self._lifespan.__aenter__()
|
||||
self._client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self.app),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
return await self._client.__aenter__()
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
try:
|
||||
await self._client.__aexit__(*exc)
|
||||
finally:
|
||||
await self._lifespan.__aexit__(*exc)
|
||||
+39
-304
@@ -22,24 +22,18 @@ 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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Homeassistant module provisioning (explicitly mock-only).
|
||||
@@ -50,13 +44,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 +91,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 +214,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 +362,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 +370,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 +381,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 +400,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 +421,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 +697,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,22 +920,37 @@ def make_config_flow_entry():
|
||||
return _factory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin-plane API fixtures (shared by the admin-plane test modules).
|
||||
#
|
||||
# The local-key/stubbed-JWKS harness itself lives in
|
||||
# :mod:`tests.admin_jwt_harness` (imported by name, the same pattern as
|
||||
# tests.blueprint_helpers); these fixtures hand its runner to every test
|
||||
# module in tests/ without re-declaring it.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path: Path) -> str:
|
||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
||||
return str(tmp_path / "nestquest.db")
|
||||
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
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(temp_db_path: str) -> httpx.AsyncClient:
|
||||
"""An app client whose provider publishes the admin test key."""
|
||||
async with AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) as client:
|
||||
yield client
|
||||
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
|
||||
@@ -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,760 +0,0 @@
|
||||
"""Admin-plane JWT auth tests: Authentik-style OIDC verification (task e70e6bfb).
|
||||
|
||||
These tests exercise the NestQuest API's admin plane against the ASGI
|
||||
app with httpx, using a locally generated RSA key pair and a stubbed
|
||||
JWKS fetcher — NO network access. That harness (the key pairs, the
|
||||
JWKS builder, the token minting and the app runner) is shared with the
|
||||
other admin-plane route tests in :mod:`tests.admin_jwt_harness`; this
|
||||
module imports it and proves the auth CONTRACT itself:
|
||||
|
||||
- ``GET /api/v1/admin/ping`` requires a valid Authentik OIDC JWT: a
|
||||
request with NO ``Authorization`` header and a MALFORMED header
|
||||
return 401; a garbage, expired, wrong-issuer, wrong-audience,
|
||||
not-yet-valid (future ``nbf``), unknown-``kid``, or bad-signature
|
||||
token returns 401; a token without ``exp`` is rejected; a VALID
|
||||
token returns 200 with the verified subject.
|
||||
- Every 401 carries the SAME detail — nothing in the response
|
||||
distinguishes which check failed.
|
||||
- The token's HEADER is validated BEFORE the JWKS cache is touched: a
|
||||
garbage or non-RS256 token triggers NO fetch at all.
|
||||
- A JWKS entry that matches the ``kid`` but carries corrupt key
|
||||
material maps to the same uniform 401.
|
||||
- The JWKS document is fetched ONCE through the app's
|
||||
:class:`api.auth.JwksCache` and then reused across requests (the
|
||||
stub fetcher counts calls); a zero TTL refetches.
|
||||
- A JWKS OUTAGE fails fast: after one failed fetch the cache re-raises
|
||||
the fault for the whole backoff window without further fetches, even
|
||||
under a burst of concurrent requests; an unknown ``kid`` forces one
|
||||
TTL-bypassing refetch, throttled to at most one per interval.
|
||||
|
||||
``ApiConfig.from_env`` requires the three OIDC variables
|
||||
(``NESTQUEST_OIDC_ISSUER``, ``NESTQUEST_OIDC_AUDIENCE``,
|
||||
``NESTQUEST_OIDC_JWKS_URL``) with the same fail-fast discipline as
|
||||
``NESTQUEST_DB_PATH`` / ``NESTQUEST_PANEL_TOKEN``: tests build the app
|
||||
with explicit ApiConfig values so they stay hermetic.
|
||||
|
||||
The signature/expiry/audience proof is real, not stubbed: tokens are
|
||||
signed with a locally generated RSA private key, the app verifies
|
||||
against the JWKS built from its public key, and the wrong-signature
|
||||
case signs with a SECOND locally generated key pair.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api.auth import JwksCache
|
||||
from api.config import ApiConfig, ConfigError
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
AUDIENCE,
|
||||
ISSUER,
|
||||
JWKS_URL,
|
||||
KID,
|
||||
OTHER_KEY,
|
||||
SUBJECT,
|
||||
AdminRunner as _AdminRunner,
|
||||
jwks_for as _jwks_for,
|
||||
make_token as _make_token,
|
||||
)
|
||||
|
||||
#: The uniform 401 detail every rejection must carry.
|
||||
REJECTION_DETAIL = "Missing or invalid admin credentials"
|
||||
|
||||
#: 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"
|
||||
)
|
||||
|
||||
|
||||
class _FailingFetch:
|
||||
"""A JWKS fetcher stub that always fails, counting the attempts."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
raise httpx.ConnectError("provider unreachable")
|
||||
|
||||
|
||||
class _FlakyFetch:
|
||||
"""A fetcher that succeeds ONCE, then fails on every later call."""
|
||||
|
||||
def __init__(self, document: dict[str, object]) -> None:
|
||||
self.document = document
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return self.document
|
||||
raise httpx.ConnectError("provider unreachable")
|
||||
|
||||
|
||||
class _FailThenServe:
|
||||
"""A fetcher that fails the FIRST call, then serves the document."""
|
||||
|
||||
def __init__(self, document: dict[str, object]) -> None:
|
||||
self.document = document
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, url: str) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise httpx.ConnectError("provider unreachable")
|
||||
return self.document
|
||||
|
||||
|
||||
def _token_with_header(
|
||||
header: dict[str, object], payload: dict[str, object]
|
||||
) -> str:
|
||||
"""Build a JWT with an EXACT header (bypassing jwt.encode's rules).
|
||||
|
||||
Used to prove header checks (``alg``, ``kid``) happen before the
|
||||
JWKS cache is consulted; the signature segment is never reached.
|
||||
"""
|
||||
def part(obj: dict[str, object]) -> str:
|
||||
raw = json.dumps(obj).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
return f"{part(header)}.{part(payload)}.c2lnbmF0dXJl"
|
||||
|
||||
|
||||
def _verified_payload() -> dict[str, object]:
|
||||
"""Claims that would verify — the header checks fire before them."""
|
||||
now = int(time.time())
|
||||
return {
|
||||
"iss": ISSUER,
|
||||
"aud": AUDIENCE,
|
||||
"sub": SUBJECT,
|
||||
"exp": now + 300,
|
||||
"nbf": now - 1,
|
||||
}
|
||||
|
||||
|
||||
# --- the happy path -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_valid_token_returns_200_with_subject(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid token returns 200 with the status and verified subject."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "sub": SUBJECT}
|
||||
|
||||
|
||||
async def test_bearer_scheme_is_case_insensitive(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""``bearer`` (lowercase) is accepted, as RFC 9110 requires."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# --- 401s: missing / malformed credentials --------------------------------
|
||||
|
||||
|
||||
async def test_missing_token_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A request with no Authorization header returns 401."""
|
||||
response = await admin_client.get("/api/v1/admin/ping")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert "www-authenticate" in {
|
||||
key.lower() for key in response.headers
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"header",
|
||||
[
|
||||
"Basic cGFuZWw6dG9rZW4=", # wrong scheme
|
||||
"Bearer", # scheme, no credential
|
||||
"Bearer ", # blank credential
|
||||
"BearerNotAToken", # no scheme separator at all
|
||||
],
|
||||
)
|
||||
async def test_malformed_header_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient, header: str
|
||||
) -> None:
|
||||
"""A malformed Authorization header returns 401."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping", headers={"Authorization": header}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_garbage_token_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A Bearer credential that is not a JWT at all returns 401."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": "Bearer not-a-jwt"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# --- 401s: token content the verifier must reject ---------------------------
|
||||
|
||||
|
||||
async def test_expired_token_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token whose exp has passed returns 401."""
|
||||
token = _make_token(expires_in=-60)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_wrong_issuer_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token issued by another issuer returns 401."""
|
||||
token = _make_token(iss="https://evil.example.com/application/o/nestquest/")
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_wrong_audience_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token minted for another audience returns 401."""
|
||||
token = _make_token(aud="some-other-client")
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_future_nbf_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token that is not yet valid (future nbf) returns 401."""
|
||||
token = _make_token(nbf_in=3600)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_token_without_exp_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token with no exp claim returns 401 (expiry is REQUIRED)."""
|
||||
token = _make_token(expires_in=None)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_unknown_kid_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token naming a kid the JWKS does not publish returns 401."""
|
||||
token = _make_token(kid="no-such-key")
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_bad_signature_is_unauthorized(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A token signed by a key the provider never published returns 401.
|
||||
|
||||
Signed with the SECOND local key pair (header kid still points at
|
||||
the published key), so the signature check itself must fail.
|
||||
"""
|
||||
token = _make_token(signing_key=OTHER_KEY)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_corrupt_jwks_entry_matching_kid_is_unauthorized(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A JWKS entry matching the kid but with corrupt key data → 401.
|
||||
|
||||
``RSAAlgorithm.from_jwk`` raises ValueError/TypeError (NOT a
|
||||
PyJWTError) on such an entry; it must map to the SAME uniform 401,
|
||||
not surface as a server error.
|
||||
"""
|
||||
corrupt_jwks: dict[str, object] = {
|
||||
"keys": [
|
||||
{"kty": "RSA", "kid": KID, "n": "AQ", "e": "AQAB"},
|
||||
]
|
||||
}
|
||||
runner = _AdminRunner(temp_db_path, corrupt_jwks)
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
|
||||
|
||||
# --- the header is checked BEFORE the JWKS cache -----------------------------
|
||||
|
||||
|
||||
async def test_garbage_token_triggers_no_jwks_fetch(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A token that is not a JWT costs NO fetch, lock or provider load."""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": "Bearer not-a-jwt"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 0
|
||||
|
||||
|
||||
async def test_non_rs256_token_triggers_no_jwks_fetch(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A structurally valid JWT with a non-RS256 alg is 401 pre-cache.
|
||||
|
||||
The header carries a kid pointing at the published key, so ONLY the
|
||||
early alg check can reject it — the fetcher must never be called.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
token = _token_with_header(
|
||||
{"alg": "HS256", "typ": "JWT", "kid": KID}, _verified_payload()
|
||||
)
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 0
|
||||
|
||||
|
||||
# --- the uniform 401 contract -----------------------------------------------
|
||||
|
||||
|
||||
async def test_every_rejection_carries_the_same_detail(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""No failure mode is distinguishable in the response body.
|
||||
|
||||
Collects the (status, detail) pair for every rejection — missing
|
||||
and malformed headers, garbage, expired, wrong issuer, wrong
|
||||
audience, future nbf, missing exp, unknown kid, bad signature —
|
||||
and asserts they are ALL the same one 401.
|
||||
"""
|
||||
cases: list[dict[str, str]] = [
|
||||
{},
|
||||
{"Authorization": "Basic cGFuZWw6dG9rZW4="},
|
||||
{"Authorization": "Bearer"},
|
||||
{"Authorization": "Bearer not-a-jwt"},
|
||||
{"Authorization": f"Bearer {_make_token(expires_in=-60)}"},
|
||||
{"Authorization": f"Bearer {_make_token(iss='https://evil.example.com/')}"},
|
||||
{"Authorization": f"Bearer {_make_token(aud='other-client')}"},
|
||||
{"Authorization": f"Bearer {_make_token(nbf_in=3600)}"},
|
||||
{"Authorization": f"Bearer {_make_token(expires_in=None)}"},
|
||||
{"Authorization": f"Bearer {_make_token(kid='no-such-key')}"},
|
||||
{"Authorization": f"Bearer {_make_token(signing_key=OTHER_KEY)}"},
|
||||
]
|
||||
observed = set()
|
||||
for headers in cases:
|
||||
response = await admin_client.get("/api/v1/admin/ping", headers=headers)
|
||||
observed.add((response.status_code, response.json()["detail"]))
|
||||
assert observed == {(401, REJECTION_DETAIL)}
|
||||
|
||||
|
||||
# --- the JWKS cache ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_jwks_is_fetched_once_across_requests(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Multiple requests reuse ONE cached JWKS document.
|
||||
|
||||
Three separately authenticated requests trigger exactly ONE stub
|
||||
fetch — the cache is per app and never per request.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(3):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert runner.fetch.calls == 1
|
||||
|
||||
|
||||
async def test_expired_ttl_refetches_the_jwks(temp_db_path: str) -> None:
|
||||
"""A lapsed TTL triggers a fresh fetch instead of serving stale keys."""
|
||||
runner = _AdminRunner(
|
||||
temp_db_path, _jwks_for(ADMIN_KEY, KID), ttl_seconds=0.0
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(2):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert runner.fetch.calls == 2
|
||||
|
||||
|
||||
# --- the JWKS cache under an outage: fail fast, don't amplify ---------------
|
||||
|
||||
|
||||
async def test_jwks_outage_fails_fast_during_backoff() -> None:
|
||||
"""After one failed fetch, later requests fail fast for the window."""
|
||||
fetch = _FailingFetch()
|
||||
cache = JwksCache(JWKS_URL, fetcher=fetch)
|
||||
for _ in range(4):
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await cache.get()
|
||||
assert fetch.calls == 1
|
||||
|
||||
|
||||
async def test_jwks_outage_burst_calls_the_fetcher_once() -> None:
|
||||
"""A burst of concurrent requests costs ONE fetch, not one each.
|
||||
|
||||
The first caller fetches and fails; every other caller — queued on
|
||||
the lock or arriving inside the backoff window — fails fast on the
|
||||
recorded fault without touching the fetcher.
|
||||
"""
|
||||
fetch = _FailingFetch()
|
||||
cache = JwksCache(JWKS_URL, fetcher=fetch)
|
||||
results = await asyncio.gather(
|
||||
*(cache.get() for _ in range(6)), return_exceptions=True
|
||||
)
|
||||
assert fetch.calls == 1
|
||||
assert all(
|
||||
isinstance(result, httpx.ConnectError) for result in results
|
||||
)
|
||||
|
||||
|
||||
async def test_jwks_backoff_lapses_and_recovers() -> None:
|
||||
"""A backoff window is not a lockout: retries resume after it.
|
||||
|
||||
With a zero-length window the very next request retries (and the
|
||||
successful document is then served from the cache), proving a
|
||||
failed state is never cached for good.
|
||||
"""
|
||||
document = _jwks_for(ADMIN_KEY, KID)
|
||||
fetch = _FailThenServe(document)
|
||||
cache = JwksCache(JWKS_URL, fetcher=fetch, backoff_seconds=0.0)
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await cache.get()
|
||||
assert await cache.get() == document
|
||||
assert await cache.get() == document
|
||||
assert fetch.calls == 2
|
||||
|
||||
|
||||
async def test_jwks_outage_is_a_server_fault_not_a_401(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A provider outage propagates as a fault, never as a 401."""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
runner.app.state.jwks_cache = JwksCache(JWKS_URL, fetcher=_FailingFetch())
|
||||
async with runner as client:
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
|
||||
|
||||
# --- the unknown-kid refetch: one forced, bounded attempt --------------------
|
||||
|
||||
|
||||
async def test_unknown_kid_refetch_picks_up_a_rotated_key(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""An unmatched kid forces ONE refetch, and the retry can verify.
|
||||
|
||||
The JWKS first publishes a DIFFERENT kid: the token is 401 after
|
||||
exactly two fetches (normal + forced). When the rotated key lands
|
||||
in the provider's document, the NEXT token forces another refetch
|
||||
that picks it up — the same token verifies WITHOUT waiting out the
|
||||
TTL.
|
||||
"""
|
||||
runner = _AdminRunner(
|
||||
temp_db_path, _jwks_for(ADMIN_KEY, "other-key"), kid_refetch_seconds=0.0
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 2
|
||||
|
||||
# The rotation lands at the provider (same stub fetcher).
|
||||
runner.fetch.document = _jwks_for(ADMIN_KEY, KID)
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert runner.fetch.calls == 3
|
||||
|
||||
|
||||
async def test_unknown_kid_refetch_is_throttled(temp_db_path: str) -> None:
|
||||
"""Back-to-back unknown-kid tokens force at most one refetch.
|
||||
|
||||
The interval bounds forced refetches: the second unknown-kid token
|
||||
inside it is served from the cached document (still unmatched →
|
||||
401) with no further fetch.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, "other-key"))
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(2):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert runner.fetch.calls == 2
|
||||
|
||||
|
||||
async def test_unknown_kid_refetch_resumes_after_the_interval(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A lapsed interval allows the next forced refetch again.
|
||||
|
||||
With a zero-length interval every request may force its refetch:
|
||||
the first costs one normal fetch plus one forced (two), and the
|
||||
second — its cached document still fresh — costs just the forced
|
||||
one (three in total), proving the throttle is a bounded interval
|
||||
rather than a permanent lockout.
|
||||
"""
|
||||
runner = _AdminRunner(
|
||||
temp_db_path, _jwks_for(ADMIN_KEY, "other-key"), kid_refetch_seconds=0.0
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
for _ in range(2):
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert runner.fetch.calls == 3
|
||||
|
||||
|
||||
async def test_failed_forced_refetch_propagates_then_fails_fast(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A failed forced refetch is a fault, and its backoff bounds the rest.
|
||||
|
||||
First request: cache fills (1 fetch), the kid is unmatched, the
|
||||
forced refetch FAILS (2 fetches) and propagates as the server
|
||||
fault. Second request inside the backoff window: the cached
|
||||
document is served, the match fails, and the token is 401 — with
|
||||
NO third fetch.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
runner.app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=_FlakyFetch(_jwks_for(ADMIN_KEY, "other-key"))
|
||||
)
|
||||
token = _make_token()
|
||||
async with runner as client:
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == REJECTION_DETAIL
|
||||
|
||||
|
||||
# --- ApiConfig.from_env: the OIDC settings -----------------------------------
|
||||
|
||||
|
||||
def _full_env() -> dict[str, str]:
|
||||
"""A from_env dict with every required variable set to a valid value."""
|
||||
return {
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": "panel-token",
|
||||
"NESTQUEST_OIDC_ISSUER": ISSUER,
|
||||
"NESTQUEST_OIDC_AUDIENCE": AUDIENCE,
|
||||
"NESTQUEST_OIDC_JWKS_URL": JWKS_URL,
|
||||
}
|
||||
|
||||
|
||||
def test_from_env_reads_oidc_settings() -> None:
|
||||
"""Set, non-empty OIDC env values are returned verbatim."""
|
||||
cfg = ApiConfig.from_env(_full_env())
|
||||
assert cfg.oidc_issuer == ISSUER
|
||||
assert cfg.oidc_audience == AUDIENCE
|
||||
assert cfg.oidc_jwks_url == JWKS_URL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_var",
|
||||
[
|
||||
"NESTQUEST_OIDC_ISSUER",
|
||||
"NESTQUEST_OIDC_AUDIENCE",
|
||||
"NESTQUEST_OIDC_JWKS_URL",
|
||||
],
|
||||
)
|
||||
def test_from_env_requires_oidc_setting(env_var: str) -> None:
|
||||
"""A missing OR empty OIDC variable raises ConfigError (fail fast)."""
|
||||
env = _full_env()
|
||||
del env[env_var]
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(env)
|
||||
env[env_var] = " "
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(env)
|
||||
|
||||
|
||||
# --- the nestquest-admins group gate (task 0f2afb29) -------------------------
|
||||
|
||||
|
||||
async def test_admin_jwt_with_group_returns_200(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid JWT whose groups claim names nestquest-admins returns 200."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "sub": SUBJECT}
|
||||
|
||||
|
||||
async def test_non_admin_jwt_is_forbidden(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid JWT WITHOUT nestquest-admins in groups returns 403."""
|
||||
token = _make_token(groups=("some-other-group",))
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_empty_groups_is_forbidden(
|
||||
admin_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A valid JWT with an EMPTY groups list returns 403."""
|
||||
token = _make_token(groups=())
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("groups", ["nestquest-admins", None])
|
||||
async def test_malformed_groups_is_forbidden(
|
||||
admin_client: httpx.AsyncClient, groups: object
|
||||
) -> None:
|
||||
"""A valid JWT with a non-list (or missing) groups claim returns 403.
|
||||
|
||||
Even a plain STRING naming the group is not enough — the claim must
|
||||
be a list, so a token crafted with a scalar ``groups`` cannot pass.
|
||||
"""
|
||||
token = _make_token(groups=groups)
|
||||
response = await admin_client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
|
||||
|
||||
async def test_panel_service_token_is_refused_outright(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""The panel service token on an admin route is 403, never a JWT.
|
||||
|
||||
403 (not the uniform 401) proves the credential was refused as a
|
||||
TYPE, not run through JWT verification — and the JWKS fetcher is
|
||||
never called, proving it was not treated as a token to verify.
|
||||
"""
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": "Bearer admin-test-panel-token"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
assert runner.fetch.calls == 0
|
||||
|
||||
|
||||
async def test_admin_router_is_wired_through_require_admin(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""The admin router's dependency IS the one require_admin dependency.
|
||||
|
||||
The router-level dependency list carries
|
||||
:func:`api.auth.require_admin`, so every admin route (current and
|
||||
later) inherits the service-token refusal and the group gate.
|
||||
"""
|
||||
from api.auth import require_admin
|
||||
from api.routes_admin import router as admin_router
|
||||
|
||||
dependencies = [d.dependency for d in admin_router.dependencies]
|
||||
assert require_admin in dependencies
|
||||
|
||||
# Behavioral proof: the probe returns 200 for an admin JWT through
|
||||
# the same dependency.
|
||||
runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
response = await client.get(
|
||||
"/api/v1/admin/ping",
|
||||
headers={"Authorization": f"Bearer {_make_token()}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
"""Admin-plane children route tests (task e4845baa).
|
||||
|
||||
These tests exercise the admin plane's children routes 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 — signature, issuer, audience, expiry and the
|
||||
nestquest-admins group — with NO network access.
|
||||
|
||||
Every done-condition case is proven against the DATABASE, read through
|
||||
the SAME core copy the app uses (``nestquest_core.*``; see the two-copy
|
||||
caveat in api/nestquest_core.py):
|
||||
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an admin JWT passes, a JWT without the group is 403, an
|
||||
absent credential is 401 — and no row is written.
|
||||
- ``POST /api/v1/admin/children``: the created child EXISTS with the
|
||||
given display name (and the optional colour/avatar_ref/sort_order).
|
||||
- ``PATCH /api/v1/admin/children/{id}``: an edit PERSISTS the supplied
|
||||
fields and leaves omitted fields untouched (exclude_unset semantics —
|
||||
an omitted field is never passed to the core).
|
||||
- ``PATCH /api/v1/admin/children/{id}/active`` with ``is_active: false``
|
||||
sets the active flag FALSE and the row STILL EXISTS (children are
|
||||
never hard-deleted).
|
||||
- ``POST /api/v1/admin/children/reorder``: the posted order becomes the
|
||||
rows' sort_order; a list that is not a complete permutation is 422
|
||||
and leaves the stored order untouched.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): a core ValueError
|
||||
for a non-existent child is 404; every other core ValueError (an empty
|
||||
display name, an explicit null colour, a bad permutation) is 422 — the
|
||||
same failure class FastAPI reports for malformed bodies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The children business layer through the SAME core copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy) —
|
||||
# used for the tests' database assertions only; the routes reach it
|
||||
# through api.nestquest_core.
|
||||
_children = importlib.import_module("nestquest_core.children")
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
async def _children_by_id(database: object) -> dict[int, object]:
|
||||
"""Every child row through the app's core copy, keyed by id."""
|
||||
records = await _children.list_children(database)
|
||||
return {record.id: record for record in records}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_children(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth tests use (stubbed JWKS, no network); the
|
||||
fixture also exposes the app's ONE database connection so tests
|
||||
assert the routes' effects on the stored rows themselves.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards every route ---
|
||||
|
||||
|
||||
async def test_children_routes_refuse_non_admin_and_absent_credentials(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_children.client
|
||||
body = {"display_name": "Ada"}
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
|
||||
forbidden = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
unauthenticated = await client.post("/api/v1/admin/children", json=body)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
unauthenticated_list = await client.get("/api/v1/admin/children")
|
||||
assert unauthenticated_list.status_code == 401
|
||||
|
||||
# No route ran its handler: the household is still empty.
|
||||
assert await _children_by_id(admin_children.database) == {}
|
||||
|
||||
|
||||
# --- create -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_child_persists_the_given_name(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""POST /children creates the row; the child exists with that name."""
|
||||
response = await admin_children.client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={
|
||||
"display_name": "Ada",
|
||||
"colour": "#ff8800",
|
||||
"avatar_ref": "avatar:bee",
|
||||
"sort_order": 2,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["display_name"] == "Ada"
|
||||
assert body["colour"] == "#ff8800"
|
||||
assert body["avatar_ref"] == "avatar:bee"
|
||||
assert body["sort_order"] == 2
|
||||
assert body["is_active"] is True
|
||||
|
||||
stored = (await _children_by_id(admin_children.database))[body["id"]]
|
||||
assert stored.display_name == "Ada"
|
||||
assert stored.colour == "#ff8800"
|
||||
assert stored.avatar_ref == "avatar:bee"
|
||||
assert stored.sort_order == 2
|
||||
assert stored.is_active is True
|
||||
|
||||
|
||||
async def test_create_child_with_only_a_name_uses_core_defaults(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Omitted optional fields default through the core layer."""
|
||||
response = await admin_children.client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Bo"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
stored = (await _children_by_id(admin_children.database))[
|
||||
response.json()["id"]
|
||||
]
|
||||
assert stored.display_name == "Bo"
|
||||
assert stored.colour is None
|
||||
assert stored.avatar_ref is None
|
||||
assert stored.sort_order == 0
|
||||
assert stored.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{}, # display_name missing
|
||||
{"display_name": 5}, # wrong type
|
||||
{"display_name": ""}, # empty: the core rejects it
|
||||
{"display_name": " "}, # whitespace-only: empty to a parent
|
||||
{"display_name": "Ada", "sort_order": "first"}, # wrong type
|
||||
],
|
||||
)
|
||||
async def test_create_child_malformed_input_is_422(
|
||||
admin_children: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""Malformed bodies are 422 and never create a row."""
|
||||
response = await admin_children.client.post(
|
||||
"/api/v1/admin/children", json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert await _children_by_id(admin_children.database) == {}
|
||||
|
||||
|
||||
# --- edit -------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_edit_child_persists_supplied_fields_and_skips_omitted_ones(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PATCH persists exactly the supplied fields (exclude_unset)."""
|
||||
client = admin_children.client
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={
|
||||
"display_name": "Ada",
|
||||
"colour": "#ff8800",
|
||||
"avatar_ref": "avatar:bee",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
child_id = created.json()["id"]
|
||||
|
||||
# Only display_name and sort_order: colour/avatar_ref must survive.
|
||||
edited = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}",
|
||||
json={"display_name": "Ada Lovelace", "sort_order": 4},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert edited.status_code == 200
|
||||
assert edited.json()["display_name"] == "Ada Lovelace"
|
||||
assert edited.json()["sort_order"] == 4
|
||||
stored = (await _children_by_id(admin_children.database))[child_id]
|
||||
assert stored.display_name == "Ada Lovelace"
|
||||
assert stored.sort_order == 4
|
||||
# The omitted fields were never passed to the core: unchanged.
|
||||
assert stored.colour == "#ff8800"
|
||||
assert stored.avatar_ref == "avatar:bee"
|
||||
|
||||
# A second edit touching only colour leaves the name alone.
|
||||
recoloured = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}",
|
||||
json={"colour": "#00aa55"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert recoloured.status_code == 200
|
||||
stored = (await _children_by_id(admin_children.database))[child_id]
|
||||
assert stored.colour == "#00aa55"
|
||||
assert stored.display_name == "Ada Lovelace"
|
||||
|
||||
|
||||
async def test_edit_unknown_child_is_404(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An edit naming a child that does not exist is 404."""
|
||||
response = await admin_children.client.patch(
|
||||
"/api/v1/admin/children/999",
|
||||
json={"display_name": "Nobody"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Child not found"
|
||||
|
||||
|
||||
async def test_edit_explicit_null_colour_is_422_and_changes_nothing(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An explicit null (clearing) is passed through and rejected by the core.
|
||||
|
||||
Only the fields the request SUPPLIES are forwarded: a null colour is
|
||||
a supplied None, which the core refuses (the DAO cannot express
|
||||
NULL) — mapped to 422, with the stored colour left untouched.
|
||||
"""
|
||||
created = await admin_children.client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada", "colour": "#ff8800"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
child_id = created.json()["id"]
|
||||
response = await admin_children.client.patch(
|
||||
f"/api/v1/admin/children/{child_id}",
|
||||
json={"colour": None},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
stored = (await _children_by_id(admin_children.database))[child_id]
|
||||
assert stored.colour == "#ff8800"
|
||||
|
||||
|
||||
# --- set_active -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_deactivation_sets_the_flag_false_and_never_deletes(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""is_active=false deactivates the row; the row STILL exists."""
|
||||
client = admin_children.client
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
child_id = created.json()["id"]
|
||||
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
assert deactivated.json()["is_active"] is False
|
||||
|
||||
# Database proof: the flag is False and the row was NOT hard-deleted.
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
stored = rows[child_id]
|
||||
assert stored.is_active is False
|
||||
assert stored.display_name == "Ada"
|
||||
|
||||
# Reactivation goes through the same route: the flag is set, not
|
||||
# one-way.
|
||||
reactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{child_id}/active",
|
||||
json={"is_active": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert reactivated.status_code == 200
|
||||
assert reactivated.json()["is_active"] is True
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
assert rows[child_id].is_active is True
|
||||
|
||||
|
||||
async def test_set_active_unknown_child_is_404(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A set_active naming a child that does not exist is 404."""
|
||||
response = await admin_children.client.patch(
|
||||
"/api/v1/admin/children/999/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Child not found"
|
||||
|
||||
|
||||
async def test_set_active_malformed_body_is_422(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-bool is_active is rejected by the body model (422)."""
|
||||
response = await admin_children.client.patch(
|
||||
"/api/v1/admin/children/1/active",
|
||||
json={"is_active": "maybe"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# --- reorder ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_reorder_writes_the_posted_order_to_sort_order(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The posted id order becomes the rows' sort_order."""
|
||||
client = admin_children.client
|
||||
ids = []
|
||||
for name in ("Ada", "Bo", "Cyd"):
|
||||
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"])
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/children/reorder",
|
||||
json={"ordered_ids": [ids[2], ids[0], ids[1]]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
# Database proof: each posted position became that child's sort_order.
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
assert rows[ids[2]].sort_order == 0
|
||||
assert rows[ids[0]].sort_order == 1
|
||||
assert rows[ids[1]].sort_order == 2
|
||||
|
||||
|
||||
async def test_reorder_rejects_non_permutations_and_writes_nothing(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A list that is not a complete permutation is 422, order intact.
|
||||
|
||||
The core rejects the list BEFORE any write, so the stored sort_order
|
||||
values survive every rejected call unchanged.
|
||||
"""
|
||||
client = admin_children.client
|
||||
ids = []
|
||||
for name in ("Ada", "Bo"):
|
||||
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"])
|
||||
|
||||
bad_lists = [
|
||||
ids[:1], # partial: missing an existing child
|
||||
[ids[0], ids[0], ids[1]], # duplicate id
|
||||
[*ids, ids[1] + 999], # unknown id
|
||||
]
|
||||
for ordered_ids in bad_lists:
|
||||
response = await client.post(
|
||||
"/api/v1/admin/children/reorder",
|
||||
json={"ordered_ids": ordered_ids},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, ordered_ids
|
||||
|
||||
# A non-integer id never reaches the core either (body model).
|
||||
malformed = await client.post(
|
||||
"/api/v1/admin/children/reorder",
|
||||
json={"ordered_ids": ["first", "second"]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert malformed.status_code == 422
|
||||
|
||||
# Database proof: no write happened for any rejected call.
|
||||
rows = await _children_by_id(admin_children.database)
|
||||
assert [rows[child_id].sort_order for child_id in ids] == [0, 0]
|
||||
|
||||
|
||||
# --- list (the read model the admin PWA re-renders from) ---------------------
|
||||
|
||||
|
||||
async def test_list_children_returns_all_in_display_order(
|
||||
admin_children: SimpleNamespace,
|
||||
) -> None:
|
||||
"""GET /children returns every child (inactive included) in order."""
|
||||
client = admin_children.client
|
||||
ada = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada", "sort_order": 5},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
bo = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Bo", "sort_order": 1},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{ada.json()['id']}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/children", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
children = response.json()["children"]
|
||||
assert [child["id"] for child in children] == [
|
||||
bo.json()["id"],
|
||||
ada.json()["id"],
|
||||
]
|
||||
by_id = {child["id"]: child for child in children}
|
||||
assert by_id[ada.json()["id"]]["is_active"] is False
|
||||
assert by_id[bo.json()["id"]]["is_active"] is True
|
||||
# The serialized shape is the documented child payload.
|
||||
assert set(by_id[bo.json()["id"]]) == {
|
||||
"id",
|
||||
"display_name",
|
||||
"colour",
|
||||
"avatar_ref",
|
||||
"sort_order",
|
||||
"is_active",
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
"""Admin-plane history route tests (task 2d2dda53).
|
||||
|
||||
These tests exercise the admin plane's history query and CSV export
|
||||
routes 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 — signature, issuer, audience,
|
||||
expiry and the nestquest-admins group — 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.*``; the
|
||||
two-copy caveat in api/nestquest_core.py), and the reversal the
|
||||
history tests read is appended through that same copy's
|
||||
:func:`nestquest_core.completion.uncomplete_instance` — so the rows
|
||||
the routes report are the rows the core's own write paths produced.
|
||||
|
||||
The seeded household (clock pinned to noon, the route's ``_local_now``
|
||||
pinned to the same instant so nothing straddles midnight):
|
||||
|
||||
- Bo's ``Brush teeth`` TODAY instance: a ``completed`` event (panel
|
||||
tap, on time) plus the appended ``uncompleted`` reversal — the
|
||||
completion and the reversal the ``all`` and ``reversals`` filters
|
||||
report.
|
||||
- Ada's ``Pack bag`` and ``Stale chore`` YESTERDAY instances and Bo's
|
||||
``Brush teeth`` YESTERDAY instance: open, past due, no events — the
|
||||
DERIVED missed rows the ``missed`` filter reports.
|
||||
|
||||
Proven per the done-condition, everything read back through the API:
|
||||
|
||||
- ``GET /api/v1/admin/history`` returns the expected rows for EACH
|
||||
filter — ``all`` (the completed event AND the reversal),
|
||||
``reversals`` (the uncompleted event only) and ``missed`` (the
|
||||
derived past-due open instances, actor ``nightly sweep``, no
|
||||
``occurred_at``) — and the range scopes by the INSTANCE's due date
|
||||
(the Feature 13 rule), so the events sit under their instance's due
|
||||
day, not the wall-clock day.
|
||||
- ``GET /api/v1/admin/history.csv`` answers ``text/csv`` with a
|
||||
``Content-Disposition`` attachment filename, and the parsed body
|
||||
(csv.reader) carries the documented header columns and exactly the
|
||||
filtered rows.
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: a non-admin JWT is 403 and an absent credential is 401
|
||||
on both routes.
|
||||
- Malformed input — an unknown filter, a non-strict date, an inverted
|
||||
range, a missing parameter — is 422 on both routes (never a 500).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import datetime
|
||||
import importlib
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from api import routes_admin
|
||||
from api.database import _executor
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
SUBJECT,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
from tests.test_api_panel import _seed_household
|
||||
|
||||
# The completion, instance-DAO and db layers through the SAME core copy
|
||||
# the app uses (nestquest_core.*, NOT the conftest-stubbed
|
||||
# custom_components copy) — used for the tests' seeding only; the
|
||||
# routes reach them through api.nestquest_core.
|
||||
_completion = importlib.import_module("nestquest_core.completion")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_core_db = importlib.import_module("nestquest_core.db")
|
||||
|
||||
#: The documented CSV columns (Admin spec §5 event-row fields), in
|
||||
#: header order — asserted literally so the contract is pinned here,
|
||||
#: independent of the core constant.
|
||||
DOCUMENTED_CSV_COLUMNS = [
|
||||
"occurred_at",
|
||||
"event_type",
|
||||
"child_name",
|
||||
"child_id",
|
||||
"quest_title",
|
||||
"instance_id",
|
||||
"window",
|
||||
"due_date",
|
||||
"due_time",
|
||||
"actor",
|
||||
"was_on_time",
|
||||
]
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _pinned_noon() -> tuple[datetime.date, datetime.datetime]:
|
||||
"""One host clock read pinned to 12:00 UTC that day.
|
||||
|
||||
The seed's completion, the reversal and the route's clock all
|
||||
anchor to this instant (the reversal five minutes later), so
|
||||
nothing can straddle midnight mid-test.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
return today, datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def history_household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""A seeded household served by the admin-plane app, clock pinned.
|
||||
|
||||
Beyond :func:`_seed_household`'s rows (Bo's today completion, the
|
||||
past-due open yesterday instances), the fixture appends ONE
|
||||
``uncompleted`` reversal on Bo's today instance through the core
|
||||
completion layer — attributed to the harness subject, stamped five
|
||||
minutes after the pinned completion — and exposes the yesterday
|
||||
instances the missed filter must report.
|
||||
"""
|
||||
today, pinned_now = _pinned_noon()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
|
||||
database = _core_db.NestQuestDatabase(_executor)
|
||||
await database.open(temp_db_path)
|
||||
try:
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
ada_yesterday = await instances.list_by_child_and_date(
|
||||
seed.ada.id, yesterday.isoformat()
|
||||
)
|
||||
pack_bag_yesterday = next(
|
||||
record for record in ada_yesterday if record.due_time == "10:00"
|
||||
)
|
||||
bo_yesterday = await instances.list_by_child_and_date(
|
||||
seed.bo.id, yesterday.isoformat()
|
||||
)
|
||||
brush_teeth_yesterday = next(
|
||||
record for record in bo_yesterday if record.due_time == "17:00"
|
||||
)
|
||||
await _completion.uncomplete_instance(
|
||||
database,
|
||||
seed.brush_teeth_instance.id,
|
||||
actor_source="user",
|
||||
actor_user_id=SUBJECT,
|
||||
now=pinned_now + datetime.timedelta(minutes=5),
|
||||
today=today,
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
monkeypatch.setattr(routes_admin, "_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,
|
||||
database=runner.app.state.db.database,
|
||||
today=today,
|
||||
yesterday=yesterday,
|
||||
pack_bag_yesterday=pack_bag_yesterday,
|
||||
brush_teeth_yesterday=brush_teeth_yesterday,
|
||||
)
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards both routes ----
|
||||
|
||||
|
||||
async def test_history_routes_refuse_non_admin_and_absent(
|
||||
history_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401, both routes."""
|
||||
client = history_household.client
|
||||
query = f"start={history_household.yesterday.isoformat()}"
|
||||
query += f"&end={history_household.today.isoformat()}"
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
for path in ("/api/v1/admin/history", "/api/v1/admin/history.csv"):
|
||||
forbidden = await client.get(
|
||||
f"{path}?{query}",
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403, path
|
||||
assert forbidden.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
unauthenticated = await client.get(f"{path}?{query}")
|
||||
assert unauthenticated.status_code == 401, path
|
||||
|
||||
|
||||
# --- the JSON history query: the expected rows per filter --------------------
|
||||
|
||||
|
||||
async def test_history_all_returns_completed_and_reversal(
|
||||
history_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""``all`` returns the completed event AND the reversal, in order.
|
||||
|
||||
Both events belong to Bo's Brush teeth instance due TODAY, so the
|
||||
due-date-scoped range [yesterday, today] reports them under today;
|
||||
the rows are ordered by due date, then instance, then event id —
|
||||
the completed row before its reversal.
|
||||
"""
|
||||
client = history_household.client
|
||||
seed = history_household.seed
|
||||
brush = seed.brush_teeth_instance
|
||||
yesterday_iso = history_household.yesterday.isoformat()
|
||||
today_iso = history_household.today.isoformat()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/history",
|
||||
params={"filter": "all", "start": yesterday_iso, "end": today_iso},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["filter"] == "all"
|
||||
assert body["start"] == yesterday_iso
|
||||
assert body["end"] == today_iso
|
||||
assert body["count"] == 2
|
||||
|
||||
completed, reversal = body["rows"]
|
||||
assert completed == {
|
||||
"occurred_at": f"{today_iso}T12:00:00+00:00",
|
||||
"event_type": "completed",
|
||||
"child_name": "Bo",
|
||||
"child_id": brush.child_id,
|
||||
"quest_title": "Brush teeth",
|
||||
"instance_id": brush.id,
|
||||
"window": "morning",
|
||||
"due_date": today_iso,
|
||||
"due_time": "17:00",
|
||||
"actor": "panel (Bo)",
|
||||
"was_on_time": True,
|
||||
}
|
||||
assert reversal == {
|
||||
"occurred_at": f"{today_iso}T12:05:00+00:00",
|
||||
"event_type": "uncompleted",
|
||||
"child_name": "Bo",
|
||||
"child_id": brush.child_id,
|
||||
"quest_title": "Brush teeth",
|
||||
"instance_id": brush.id,
|
||||
"window": "morning",
|
||||
"due_date": today_iso,
|
||||
"due_time": "17:00",
|
||||
"actor": SUBJECT,
|
||||
"was_on_time": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_history_reversals_returns_only_the_uncompleted_event(
|
||||
history_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""``reversals`` returns the uncompleted event only, user-attributed."""
|
||||
client = history_household.client
|
||||
seed = history_household.seed
|
||||
brush = seed.brush_teeth_instance
|
||||
today_iso = history_household.today.isoformat()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/history",
|
||||
params={
|
||||
"filter": "reversals",
|
||||
"start": today_iso,
|
||||
"end": today_iso,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["filter"] == "reversals"
|
||||
assert body["count"] == 1
|
||||
(reversal,) = body["rows"]
|
||||
assert reversal["event_type"] == "uncompleted"
|
||||
assert reversal["instance_id"] == brush.id
|
||||
assert reversal["actor"] == SUBJECT
|
||||
assert reversal["occurred_at"] == f"{today_iso}T12:05:00+00:00"
|
||||
assert reversal["was_on_time"] is None
|
||||
|
||||
|
||||
async def test_history_missed_returns_derived_missed_instances(
|
||||
history_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""``missed`` returns the DERIVED past-due open instances.
|
||||
|
||||
Three seeded instances are open and due yesterday: Ada's Pack bag
|
||||
and Stale chore and Bo's Brush teeth. Each is a DERIVED row —
|
||||
never a stored event: no ``occurred_at``, ``was_on_time`` None and
|
||||
the spec's ``nightly sweep`` actor.
|
||||
"""
|
||||
client = history_household.client
|
||||
seed = history_household.seed
|
||||
yesterday_iso = history_household.yesterday.isoformat()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/history",
|
||||
params={
|
||||
"filter": "missed",
|
||||
"start": yesterday_iso,
|
||||
"end": yesterday_iso,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["filter"] == "missed"
|
||||
assert body["count"] == 3
|
||||
|
||||
rows = body["rows"]
|
||||
assert all(row["event_type"] == "missed" for row in rows)
|
||||
assert all(row["occurred_at"] is None for row in rows)
|
||||
assert all(row["was_on_time"] is None for row in rows)
|
||||
assert all(row["actor"] == "nightly sweep" for row in rows)
|
||||
assert all(row["due_date"] == yesterday_iso for row in rows)
|
||||
assert {
|
||||
(row["child_name"], row["quest_title"], row["due_time"])
|
||||
for row in rows
|
||||
} == {
|
||||
("Ada", "Pack bag", "10:00"),
|
||||
("Bo", "Brush teeth", "17:00"),
|
||||
("Ada", "Stale chore", "09:00"),
|
||||
}
|
||||
assert {
|
||||
row["instance_id"]
|
||||
for row in rows
|
||||
} == {
|
||||
history_household.pack_bag_yesterday.id,
|
||||
history_household.brush_teeth_yesterday.id,
|
||||
seed.stale_instance.id,
|
||||
}
|
||||
# Deterministic order: due date, then instance id.
|
||||
keys = [(row["due_date"], row["instance_id"]) for row in rows]
|
||||
assert keys == sorted(keys)
|
||||
|
||||
|
||||
async def test_history_range_scopes_by_the_instance_due_date(
|
||||
history_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The range scopes by the INSTANCE's due date, not the wall clock.
|
||||
|
||||
Both events OCCURRED today but belong to the instance due today, so
|
||||
a yesterday-only ``all`` range reports nothing; the missed filter
|
||||
over a today-only range reports nothing either (a due-TODAY open
|
||||
instance is not past due).
|
||||
"""
|
||||
client = history_household.client
|
||||
yesterday_iso = history_household.yesterday.isoformat()
|
||||
today_iso = history_household.today.isoformat()
|
||||
|
||||
yesterday_all = await client.get(
|
||||
"/api/v1/admin/history",
|
||||
params={"filter": "all", "start": yesterday_iso, "end": yesterday_iso},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert yesterday_all.status_code == 200
|
||||
assert yesterday_all.json()["count"] == 0
|
||||
|
||||
today_missed = await client.get(
|
||||
"/api/v1/admin/history",
|
||||
params={"filter": "missed", "start": today_iso, "end": today_iso},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert today_missed.status_code == 200
|
||||
assert today_missed.json()["count"] == 0
|
||||
|
||||
|
||||
# --- the CSV export: documented columns and the filtered rows ----------------
|
||||
|
||||
|
||||
async def test_history_csv_carries_documented_columns_and_all_rows(
|
||||
history_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The CSV export carries the documented header and the filtered rows.
|
||||
|
||||
The body is parsed with csv.reader: the first row is the documented
|
||||
column set, the remaining rows are exactly the ``all`` filter's
|
||||
rows (the completion and its reversal) in the same order the JSON
|
||||
query reports them.
|
||||
"""
|
||||
client = history_household.client
|
||||
seed = history_household.seed
|
||||
brush = seed.brush_teeth_instance
|
||||
yesterday_iso = history_household.yesterday.isoformat()
|
||||
today_iso = history_household.today.isoformat()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/history.csv",
|
||||
params={"filter": "all", "start": yesterday_iso, "end": today_iso},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/csv")
|
||||
disposition = response.headers["content-disposition"]
|
||||
assert disposition.startswith("attachment;")
|
||||
assert (
|
||||
f'filename="nestquest-history-{yesterday_iso}-to-{today_iso}.csv"'
|
||||
in disposition
|
||||
)
|
||||
|
||||
parsed = list(csv.reader(io.StringIO(response.text)))
|
||||
assert parsed[0] == DOCUMENTED_CSV_COLUMNS
|
||||
assert len(parsed) == 3 # header + completed + reversal
|
||||
assert parsed[1] == [
|
||||
f"{today_iso}T12:00:00+00:00",
|
||||
"completed",
|
||||
"Bo",
|
||||
str(brush.child_id),
|
||||
"Brush teeth",
|
||||
str(brush.id),
|
||||
"morning",
|
||||
today_iso,
|
||||
"17:00",
|
||||
"panel (Bo)",
|
||||
"true",
|
||||
]
|
||||
assert parsed[2] == [
|
||||
f"{today_iso}T12:05:00+00:00",
|
||||
"uncompleted",
|
||||
"Bo",
|
||||
str(brush.child_id),
|
||||
"Brush teeth",
|
||||
str(brush.id),
|
||||
"morning",
|
||||
today_iso,
|
||||
"17:00",
|
||||
SUBJECT,
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
async def test_history_csv_missed_rows_are_derived_rows(
|
||||
history_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The CSV export of the ``missed`` filter carries the derived rows.
|
||||
|
||||
Same filtered range as the JSON query's missed answer: three rows
|
||||
with an empty ``occurred_at`` (no event exists), the ``missed``
|
||||
event type and the ``nightly sweep`` actor.
|
||||
"""
|
||||
client = history_household.client
|
||||
seed = history_household.seed
|
||||
yesterday_iso = history_household.yesterday.isoformat()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/history.csv",
|
||||
params={
|
||||
"filter": "missed",
|
||||
"start": yesterday_iso,
|
||||
"end": yesterday_iso,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
parsed = list(csv.reader(io.StringIO(response.text)))
|
||||
assert parsed[0] == DOCUMENTED_CSV_COLUMNS
|
||||
assert len(parsed) == 4 # header + the three derived missed rows
|
||||
missed_rows = parsed[1:]
|
||||
assert all(row[1] == "missed" for row in missed_rows)
|
||||
assert all(row[0] == "" for row in missed_rows)
|
||||
assert all(row[9] == "nightly sweep" for row in missed_rows)
|
||||
assert all(row[10] == "" for row in missed_rows)
|
||||
assert {
|
||||
(row[2], row[4], row[8]) for row in missed_rows
|
||||
} == {
|
||||
("Ada", "Pack bag", "10:00"),
|
||||
("Bo", "Brush teeth", "17:00"),
|
||||
("Ada", "Stale chore", "09:00"),
|
||||
}
|
||||
assert {row[5] for row in missed_rows} == {
|
||||
str(history_household.pack_bag_yesterday.id),
|
||||
str(history_household.brush_teeth_yesterday.id),
|
||||
str(seed.stale_instance.id),
|
||||
}
|
||||
|
||||
|
||||
# --- validation: malformed filter and date range are 422, never 500 ----------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
{"filter": "everything", "start": "2026-03-01", "end": "2026-03-02"},
|
||||
{"filter": "all", "start": "2026/03/01", "end": "2026-03-02"},
|
||||
{"filter": "all", "start": "2026-3-1", "end": "2026-03-02"},
|
||||
{"filter": "all", "start": "2026-03-02", "end": "2026-03-01"},
|
||||
{"filter": "all", "start": "not-a-date", "end": "2026-03-02"},
|
||||
],
|
||||
)
|
||||
async def test_history_query_rejects_malformed_params_as_422(
|
||||
history_household: SimpleNamespace, params: dict[str, str]
|
||||
) -> None:
|
||||
"""An unknown filter or a rejected range is 422 naming the field."""
|
||||
client = history_household.client
|
||||
response = await client.get(
|
||||
"/api/v1/admin/history", params=params, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422, params
|
||||
detail = response.json()["detail"]
|
||||
assert any(
|
||||
name in detail for name in ("filter", "start", "end")
|
||||
), detail
|
||||
|
||||
|
||||
async def test_history_csv_rejects_malformed_params_as_422(
|
||||
history_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The CSV route maps the same rejections to 422."""
|
||||
client = history_household.client
|
||||
unknown_filter = await client.get(
|
||||
"/api/v1/admin/history.csv",
|
||||
params={"filter": "galaxy", "start": "2026-03-01", "end": "2026-03-02"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown_filter.status_code == 422
|
||||
assert "filter" in unknown_filter.json()["detail"]
|
||||
|
||||
inverted = await client.get(
|
||||
"/api/v1/admin/history.csv",
|
||||
params={"filter": "all", "start": "2026-03-02", "end": "2026-03-01"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert inverted.status_code == 422
|
||||
assert "end must be on or after start" in inverted.json()["detail"]
|
||||
|
||||
|
||||
async def test_history_query_missing_params_are_422(
|
||||
history_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A missing required query parameter is FastAPI's 422 (the shape)."""
|
||||
client = history_household.client
|
||||
no_params = await client.get(
|
||||
"/api/v1/admin/history", headers=_admin_headers()
|
||||
)
|
||||
assert no_params.status_code == 422
|
||||
missing_start = await client.get(
|
||||
"/api/v1/admin/history",
|
||||
params={"end": "2026-03-02"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert missing_start.status_code == 422
|
||||
missing_end = await client.get(
|
||||
"/api/v1/admin/history",
|
||||
params={"start": "2026-03-01"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert missing_end.status_code == 422
|
||||
@@ -1,571 +0,0 @@
|
||||
"""Admin-plane presence route tests (task e4a9d31b).
|
||||
|
||||
These tests exercise the admin plane's presence routes 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 — signature, issuer, audience, expiry and the
|
||||
nestquest-admins group — with NO network access.
|
||||
|
||||
Every done-condition case is proven against the DATABASE and the DOMAIN,
|
||||
read through the SAME core copy the app uses (``nestquest_core.*``; see
|
||||
the two-copy caveat in api/nestquest_core.py):
|
||||
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an admin JWT passes, a JWT without the group is 403, an
|
||||
absent credential is 401 — and nothing is written.
|
||||
- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the
|
||||
first set persists the schedule, setting again REPLACES it — one
|
||||
schedule row per child, never a duplicate.
|
||||
- ``POST /api/v1/admin/presence-overrides`` creates the override (its
|
||||
``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}``
|
||||
removes it again.
|
||||
- The child's RESOLVED presence changes accordingly: the pure
|
||||
:class:`~nestquest_core.presence.PresenceEngine` built from the
|
||||
stored rows answers differently before/after each write — a schedule
|
||||
restricts the child's present weekdays, an override beats the pattern
|
||||
for its range, and deleting the override restores the pattern's
|
||||
answer.
|
||||
- The regeneration side effect lives in the core layer the route
|
||||
calls: setting an all-absent schedule through the route deletes the
|
||||
child's future open quest instances and they are NOT rebuilt while
|
||||
the child is absent every day — the same proof the HA service test
|
||||
makes, through the API instead.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): a core ValueError
|
||||
for a non-existent child (schedule or override create) or override
|
||||
(delete) is 404; every other core ValueError — a rejected schedule
|
||||
(cycle length, pattern coverage, anchor date) or override (malformed or
|
||||
inverted dates, an overlapping range) — is 422, the same failure class
|
||||
FastAPI reports for malformed bodies.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
# The presence model, DAOs, business layer and materializer through the
|
||||
# SAME core copy the app uses (nestquest_core.*, NOT the conftest-stubbed
|
||||
# custom_components copy) — used for the tests' database and resolved-
|
||||
# presence assertions only; the routes reach them through api.nestquest_core.
|
||||
_children = importlib.import_module("nestquest_core.children")
|
||||
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
_presence = importlib.import_module("nestquest_core.presence")
|
||||
|
||||
#: A Monday anchoring every schedule: cycle week 0 starts here.
|
||||
ANCHOR_MONDAY = "2026-01-05"
|
||||
#: The Tuesday of that week — absent under the test schedule.
|
||||
TUESDAY = "2026-01-06"
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _schedule_body(
|
||||
cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None
|
||||
) -> dict[str, object]:
|
||||
"""A valid schedule body anchored to the test Monday."""
|
||||
return {
|
||||
"cycle_length_weeks": cycle_length_weeks,
|
||||
"anchor_date": ANCHOR_MONDAY,
|
||||
"pattern": {"0": [0, 2, 4]} if pattern is None else pattern,
|
||||
}
|
||||
|
||||
|
||||
def _override_body(
|
||||
child_id: int,
|
||||
*,
|
||||
start_date: str = TUESDAY,
|
||||
end_date: str = TUESDAY,
|
||||
is_present: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""A valid single-day override body for the test child."""
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"is_present": is_present,
|
||||
"note": "grandmother's week",
|
||||
}
|
||||
|
||||
|
||||
async def _create_child(client: object, name: str = "Ada") -> 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"]
|
||||
|
||||
|
||||
def _engine(
|
||||
database: object,
|
||||
child_id: int,
|
||||
schedule_record: object | None,
|
||||
override_records: list[object],
|
||||
) -> object:
|
||||
"""Build the pure PresenceEngine from stored rows (the snapshot shape).
|
||||
|
||||
Mirrors what the core snapshot builder assembles: the child's
|
||||
decoded schedule (absent → present every day) plus its overrides,
|
||||
so :meth:`PresenceEngine.is_present` answers from the SAME rows the
|
||||
routes produced.
|
||||
"""
|
||||
schedules: dict[int, object] = {}
|
||||
if schedule_record is not None:
|
||||
schedules[child_id] = _presence.PresenceSchedule.decode(
|
||||
schedule_record.child_id,
|
||||
schedule_record.anchor_date,
|
||||
schedule_record.pattern,
|
||||
)
|
||||
overrides = {
|
||||
child_id: [
|
||||
_presence.PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
note=record.note,
|
||||
)
|
||||
for record in override_records
|
||||
]
|
||||
}
|
||||
return _presence.PresenceEngine(schedules, overrides)
|
||||
|
||||
|
||||
async def _child_state(
|
||||
database: object, child_id: int
|
||||
) -> tuple[object | None, list[object], object]:
|
||||
"""The child's stored schedule record, overrides and PresenceEngine."""
|
||||
schedule_record = await _dao_presence.PresenceSchedulesDao(
|
||||
database
|
||||
).get_by_child(child_id)
|
||||
override_records = await _dao_presence.PresenceOverridesDao(
|
||||
database
|
||||
).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01")
|
||||
engine = _engine(database, child_id, schedule_record, override_records)
|
||||
return schedule_record, override_records, engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_presence(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth, children and quest-definition tests use
|
||||
(stubbed JWKS, no network); the fixture also exposes the app's ONE
|
||||
database connection so tests assert the routes' effects on the
|
||||
stored rows and the resolved presence built from them.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards every route ---
|
||||
|
||||
|
||||
async def test_presence_routes_refuse_non_admin_and_absent_credentials(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
non_admin_headers = {"Authorization": f"Bearer {non_admin}"}
|
||||
|
||||
forbidden_schedule = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=non_admin_headers,
|
||||
)
|
||||
assert forbidden_schedule.status_code == 403
|
||||
assert forbidden_schedule.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
forbidden_override = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id),
|
||||
headers=non_admin_headers,
|
||||
)
|
||||
assert forbidden_override.status_code == 403
|
||||
|
||||
forbidden_delete = await client.delete(
|
||||
"/api/v1/admin/presence-overrides/1", headers=non_admin_headers
|
||||
)
|
||||
assert forbidden_delete.status_code == 403
|
||||
|
||||
unauthenticated_schedule = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
)
|
||||
assert unauthenticated_schedule.status_code == 401
|
||||
|
||||
unauthenticated_override = await client.post(
|
||||
"/api/v1/admin/presence-overrides", json=_override_body(child_id)
|
||||
)
|
||||
assert unauthenticated_override.status_code == 401
|
||||
|
||||
unauthenticated_delete = await client.delete(
|
||||
"/api/v1/admin/presence-overrides/1"
|
||||
)
|
||||
assert unauthenticated_delete.status_code == 401
|
||||
|
||||
# No route ran its handler: the child still has no schedule and no
|
||||
# overrides (and is present every day, the no-schedule default).
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert _schedule is None
|
||||
assert _overrides == []
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
|
||||
|
||||
# --- schedule upsert --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_schedule_upserts_and_replaces_one_row_per_child(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PUT /presence-schedule persists, and setting again REPLACES it."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
|
||||
first = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.json() == {
|
||||
"child_id": child_id,
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": ANCHOR_MONDAY,
|
||||
"pattern": {"0": [0, 2, 4]},
|
||||
}
|
||||
# Database proof: exactly ONE schedule row (the schema's
|
||||
# UNIQUE(child_id) makes a second row unrepresentable), stored as
|
||||
# encoded CSV.
|
||||
schedule, _overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert schedule is not None
|
||||
row_id = schedule.id
|
||||
assert schedule.cycle_length_weeks == 1
|
||||
assert schedule.anchor_date == ANCHOR_MONDAY
|
||||
assert schedule.pattern == "0,2,4"
|
||||
|
||||
# Setting again UPSERTS: the SAME row is updated in place — new
|
||||
# values, unchanged id — never a duplicate or a stale first set.
|
||||
second = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(
|
||||
cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]}
|
||||
),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json() == {
|
||||
"child_id": child_id,
|
||||
"cycle_length_weeks": 2,
|
||||
"anchor_date": ANCHOR_MONDAY,
|
||||
"pattern": {"0": [], "1": [1, 3]},
|
||||
}
|
||||
schedule, _overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert schedule is not None
|
||||
assert schedule.id == row_id
|
||||
assert schedule.cycle_length_weeks == 2
|
||||
assert schedule.pattern == "|1,3"
|
||||
|
||||
|
||||
async def test_schedule_changes_the_childs_resolved_presence(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The resolved presence (PresenceEngine) follows the stored schedule."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
|
||||
# Before any schedule the child is present every day.
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
assert engine.is_present(child_id, TUESDAY) is True
|
||||
|
||||
# Mon/Wed/Fri only, week 0 of a one-week cycle.
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
assert engine.is_present(child_id, TUESDAY) is False
|
||||
|
||||
|
||||
async def test_schedule_upsert_regenerates_future_instances(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Setting an all-absent schedule deletes the child's future instances.
|
||||
|
||||
The regeneration side effect lives in the core layer the route
|
||||
calls: the child's open instances at or after today are deleted
|
||||
across ALL its definitions and NOT rebuilt while the child is
|
||||
absent every day — the same proof the HA service test makes,
|
||||
exercised here through the API route instead.
|
||||
"""
|
||||
client = admin_presence.client
|
||||
database = admin_presence.database
|
||||
child_id = await _create_child(client)
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
today = datetime.date.today()
|
||||
start = today.isoformat()
|
||||
end = (today + datetime.timedelta(days=14)).isoformat()
|
||||
await _materialize.materialize(database, start, end, today=today)
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
assert await instances.list_by_date_range(child_id, start, end)
|
||||
|
||||
# The route's write must regenerate: absent every day removes every
|
||||
# open future instance.
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(pattern={"0": []}),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert await instances.list_by_date_range(child_id, start, end) == []
|
||||
|
||||
|
||||
# --- override create + delete -----------------------------------------------
|
||||
|
||||
|
||||
async def test_override_is_created_then_deleted_and_presence_resolves(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""POST creates the override, DELETE removes it; presence follows."""
|
||||
client = admin_presence.client
|
||||
database = admin_presence.database
|
||||
child_id = await _create_child(client)
|
||||
schedule_response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert schedule_response.status_code == 200
|
||||
|
||||
# Create: the override comes back with its id and the posted shape.
|
||||
created = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
payload = created.json()
|
||||
assert set(payload) == {
|
||||
"id",
|
||||
"child_id",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"is_present",
|
||||
"note",
|
||||
}
|
||||
assert payload["child_id"] == child_id
|
||||
assert payload["start_date"] == TUESDAY
|
||||
assert payload["end_date"] == TUESDAY
|
||||
assert payload["is_present"] is True
|
||||
assert payload["note"] == "grandmother's week"
|
||||
|
||||
# Database proof: the row exists — and the RESOLVED presence for the
|
||||
# affected date flips: the override beats the Tuesday-absent pattern.
|
||||
_schedule, overrides, engine = await _child_state(database, child_id)
|
||||
assert [record.id for record in overrides] == [payload["id"]]
|
||||
assert engine.is_present(child_id, TUESDAY) is True
|
||||
# A date the override does not cover keeps the pattern's answer.
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
|
||||
# Delete: the row is gone and the resolved presence follows the
|
||||
# pattern again.
|
||||
deleted = await client.delete(
|
||||
f"/api/v1/admin/presence-overrides/{payload['id']}",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json() == {"status": "ok"}
|
||||
assert (
|
||||
await _dao_presence.PresenceOverridesDao(database).get(payload["id"])
|
||||
is None
|
||||
)
|
||||
_schedule, overrides, engine = await _child_state(database, child_id)
|
||||
assert overrides == []
|
||||
assert engine.is_present(child_id, TUESDAY) is False
|
||||
|
||||
|
||||
# --- error mapping: 404 vs 422 ----------------------------------------------
|
||||
|
||||
|
||||
async def test_schedule_and_override_for_unknown_child_are_404(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A presence write naming a child that does not exist is 404."""
|
||||
client = admin_presence.client
|
||||
|
||||
schedule = await client.put(
|
||||
"/api/v1/admin/children/999/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert schedule.status_code == 404
|
||||
assert schedule.json()["detail"] == "Child not found"
|
||||
|
||||
override = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(999),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert override.status_code == 404
|
||||
assert override.json()["detail"] == "Child not found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
_schedule_body(cycle_length_weeks=0), # below the 1..4 range
|
||||
_schedule_body(cycle_length_weeks=5), # above the documented cap
|
||||
# week 1 of the cycle missing:
|
||||
_schedule_body(cycle_length_weeks=2, pattern={"0": [0]}),
|
||||
{
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": "2026-13-40",
|
||||
"pattern": {"0": [0]},
|
||||
},
|
||||
_schedule_body(pattern={"0": [7]}), # weekday out of 0..6
|
||||
_schedule_body(pattern={"nope": [0]}), # week index not an integer
|
||||
_schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list
|
||||
],
|
||||
)
|
||||
async def test_rejected_schedule_is_422(
|
||||
admin_presence: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""A rejected schedule is 422 and stores nothing."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert (
|
||||
await _dao_presence.PresenceSchedulesDao(
|
||||
admin_presence.database
|
||||
).get_by_child(child_id)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
_override_body(1, start_date="2026-01-07", end_date="2026-01-06"),
|
||||
_override_body(1, start_date="2026-02-30", end_date="2026-02-30"),
|
||||
dict(_override_body(1), note=5),
|
||||
dict(_override_body(1), child_id="one"),
|
||||
],
|
||||
)
|
||||
async def test_rejected_override_is_422(
|
||||
admin_presence: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""A rejected override (inverted/malformed dates, bad note/id) is 422.
|
||||
|
||||
The shape failures (a non-string note, a non-integer child id) are
|
||||
the body model's; the date failures are the PresenceOverride
|
||||
model's. A string ``is_present`` like ``"yes"`` is deliberately
|
||||
absent: Pydantic's lax bool COERCES it to ``True`` (the same design
|
||||
as the children active route), so it never reaches the model's
|
||||
real-bool check.
|
||||
"""
|
||||
response = await admin_presence.client.post(
|
||||
"/api/v1/admin/presence-overrides", json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_overlapping_override_is_422_and_first_survives(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An override overlapping the child's existing one is 422.
|
||||
|
||||
The conflicting write persists nothing: the child's original
|
||||
override survives unchanged.
|
||||
"""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
created = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id, is_present=False),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
_schedule, overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert [record.id for record in overrides] == [created.json()["id"]]
|
||||
|
||||
|
||||
async def test_delete_unknown_override_is_404(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Deleting an override id that does not exist is 404."""
|
||||
response = await admin_presence.client.delete(
|
||||
"/api/v1/admin/presence-overrides/999", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Presence override not found"
|
||||
|
||||
malformed = await admin_presence.client.delete(
|
||||
"/api/v1/admin/presence-overrides/not-an-id",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert malformed.status_code == 422
|
||||
@@ -1,734 +0,0 @@
|
||||
"""Admin-plane quest-definition route tests (task b586a2f1).
|
||||
|
||||
These tests exercise the admin plane's quest-definition routes 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 — signature, issuer, audience, expiry and the
|
||||
nestquest-admins group — with NO network access.
|
||||
|
||||
Every done-condition case is proven against the DATABASE, read through
|
||||
the SAME core copy the app uses (``nestquest_core.*``; see the two-copy
|
||||
caveat in api/nestquest_core.py):
|
||||
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an admin JWT passes, a JWT without the group is 403, an
|
||||
absent credential is 401 — and no definition is written.
|
||||
- ``POST /api/v1/admin/quest-definitions``: the created definition
|
||||
EXISTS with its title, description, icon, decoded rule, assignees
|
||||
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;
|
||||
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);
|
||||
reactivation restores it.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): a core ValueError
|
||||
for a non-existent DEFINITION is 404; every other core ValueError — a
|
||||
rejected rule (RuleValidationError), a bad window name or due time, a
|
||||
rejected assignee list (an unknown assignee arrives in the BODY, so it
|
||||
is 422, never 404) — is 422, the same failure class FastAPI reports
|
||||
for malformed bodies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The quest-definition business layer and its DAO through the SAME core
|
||||
# copy the app uses (nestquest_core.*, NOT the conftest-stubbed
|
||||
# custom_components copy) — used for the tests' database assertions
|
||||
# only; the routes reach them through api.nestquest_core.
|
||||
_quest_definitions = importlib.import_module(
|
||||
"nestquest_core.quest_definitions"
|
||||
)
|
||||
_dao_rules = importlib.import_module("nestquest_core.dao_rules")
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _weekly_rule_body() -> dict[str, object]:
|
||||
"""A valid weekly rule body: Mondays and Wednesdays from 2026-03-02."""
|
||||
return {
|
||||
"rule_type": "weekly",
|
||||
"interval": 1,
|
||||
"weekday_set": [0, 2],
|
||||
"start_date": "2026-03-02",
|
||||
}
|
||||
|
||||
|
||||
def _weekly_rule_dict() -> dict[str, object]:
|
||||
"""The lossless dict form _weekly_rule_body() must decode back to."""
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
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]
|
||||
) -> object:
|
||||
"""POST a valid two-window definition for the given assignees."""
|
||||
return await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": assignee_ids,
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
|
||||
async def _definitions_by_id(database: object) -> dict[int, object]:
|
||||
"""Every ACTIVE definition bundle through the app's core copy."""
|
||||
bundles = await _quest_definitions.list_active_definitions(database)
|
||||
return {bundle.definition.id: bundle for bundle in bundles}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_questdefs(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth and children tests use (stubbed JWKS, no
|
||||
network); the fixture also exposes the app's ONE database connection
|
||||
so tests assert the routes' effects on the stored rows themselves.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards every route ---
|
||||
|
||||
|
||||
async def test_quest_definition_routes_refuse_non_admin_and_absent(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_questdefs.client
|
||||
body = {"title": "Sweep the porch"}
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
|
||||
forbidden = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
unauthenticated_create = await client.post(
|
||||
"/api/v1/admin/quest-definitions", json=body
|
||||
)
|
||||
assert unauthenticated_create.status_code == 401
|
||||
|
||||
unauthenticated_edit = await client.patch(
|
||||
"/api/v1/admin/quest-definitions/1", json={"title": "X"}
|
||||
)
|
||||
assert unauthenticated_edit.status_code == 401
|
||||
|
||||
unauthenticated_active = await client.patch(
|
||||
"/api/v1/admin/quest-definitions/1/active", json={"is_active": False}
|
||||
)
|
||||
assert unauthenticated_active.status_code == 401
|
||||
|
||||
# No route ran its handler: no definition was written.
|
||||
assert await _definitions_by_id(admin_questdefs.database) == {}
|
||||
|
||||
|
||||
# --- create -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_persists_definition_with_assignees_and_windows(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""POST /quest-definitions persists rule, assignees and windows."""
|
||||
client = admin_questdefs.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada, bo],
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
definition_id = payload["id"]
|
||||
# The serialized shape is the documented definition payload.
|
||||
assert set(payload) == {
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"icon",
|
||||
"is_active",
|
||||
"skip_on_away",
|
||||
"rule",
|
||||
"assignees",
|
||||
"windows",
|
||||
}
|
||||
assert payload["title"] == "Tidy the den"
|
||||
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",
|
||||
bo: "Bo",
|
||||
}
|
||||
assert {w["window"]: w["due_time"] for w in payload["windows"]} == {
|
||||
"morning": None,
|
||||
"evening": "19:30",
|
||||
}
|
||||
|
||||
# Database proof through the app's core copy: the definition, its
|
||||
# decoded rule, its assignees and its windows all exist as posted.
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Tidy the den"
|
||||
assert stored.definition.description == (
|
||||
"Put everything back where it belongs."
|
||||
)
|
||||
assert stored.definition.icon == "toy-box"
|
||||
assert stored.definition.is_active is True
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
assert sorted(child.id for child in stored.assignees) == [ada, bo]
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("morning", None),
|
||||
("evening", "19:30"),
|
||||
}
|
||||
|
||||
|
||||
async def test_create_with_required_fields_only_uses_core_defaults(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Omitted optional fields default to NULL through the core layer."""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Water the plants",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
assert payload["description"] is None
|
||||
assert payload["icon"] is None
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
payload["id"]
|
||||
]
|
||||
assert stored.definition.description is None
|
||||
assert stored.definition.icon is None
|
||||
|
||||
|
||||
async def test_create_without_required_fields_is_422(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A body missing any required field is 422 and creates nothing."""
|
||||
client = admin_questdefs.client
|
||||
bodies: list[dict[str, object]] = [
|
||||
{}, # everything missing
|
||||
{"title": "Tidy the den"}, # rule, assignees, windows missing
|
||||
{
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
}, # assignees and windows missing
|
||||
{
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [1],
|
||||
}, # windows missing
|
||||
]
|
||||
for body in bodies:
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, body
|
||||
assert await _definitions_by_id(admin_questdefs.database) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override",
|
||||
[
|
||||
{"title": ""}, # empty: the core rejects it
|
||||
{"title": " "}, # whitespace-only: empty to a parent
|
||||
{"assignee_child_ids": []}, # empty assignee list
|
||||
{"assignee_child_ids": [999, 1000]}, # unknown children: 422 not 404
|
||||
{"assignee_child_ids": ["Ada"]}, # wrong-typed ids
|
||||
{"windows": []}, # empty window set
|
||||
{"windows": "morning"}, # not a list
|
||||
{"windows": ["attic"]}, # not a const.QUEST_WINDOWS name
|
||||
{"windows": [["morning", "25:00"]]}, # not a 24-hour time
|
||||
{"windows": [["morning", "8:00"]]}, # not a strict HH:MM
|
||||
{"windows": [["morning", None], "morning"]}, # duplicate window
|
||||
{"rule": {"rule_type": "weekly"}}, # weekly without weekday_set
|
||||
{"rule": {"rule_type": "fortnightly"}}, # unknown rule_type
|
||||
{"rule": {"rule_type": "daily", "interval": 0}}, # interval < 1
|
||||
{"rule": {"rule_type": "weekly", "weekday_set": [7]}}, # out of range
|
||||
{"rule": {"rule_type": "monthly_day"}}, # missing day_of_month
|
||||
{"rule": {"rule_type": "daily", "day_of_month": 5}}, # forbidden field
|
||||
{"rule": {"rule_type": "daily", "start_date": "2026-13-01"}},
|
||||
{"rule": None}, # an explicit null rule is a supplied invalid value
|
||||
],
|
||||
)
|
||||
async def test_create_rejections_are_422_and_persist_nothing(
|
||||
admin_questdefs: SimpleNamespace, override: dict[str, object]
|
||||
) -> None:
|
||||
"""Rejected bodies are 422 and never create a definition."""
|
||||
client = admin_questdefs.client
|
||||
ids = await _create_children(client, ("Ada", "Bo"))
|
||||
body: dict[str, object] = {
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": ids,
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
}
|
||||
body.update(override)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, override
|
||||
assert await _definitions_by_id(admin_questdefs.database) == {}
|
||||
|
||||
|
||||
# --- edit -------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PATCH persists exactly the supplied fields (exclude_unset)."""
|
||||
client = admin_questdefs.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
created = await _create_definition(client, [ada, bo])
|
||||
assert created.status_code == 201
|
||||
definition_id = created.json()["id"]
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
original_rule_id = (await dao.get(definition_id)).schedule_rule_id
|
||||
|
||||
# Only title and windows: description, icon, rule and assignees
|
||||
# must survive; the supplied windows REPLACE the whole set.
|
||||
edited = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"title": "Clear the den", "windows": ["afternoon"]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert edited.status_code == 200
|
||||
assert edited.json()["title"] == "Clear the den"
|
||||
assert {w["window"]: w["due_time"] for w in edited.json()["windows"]} == {
|
||||
"afternoon": None
|
||||
}
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Clear the den"
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("afternoon", None)
|
||||
}
|
||||
# Omitted fields were never passed to the core: unchanged.
|
||||
assert stored.definition.description == (
|
||||
"Put everything back where it belongs."
|
||||
)
|
||||
assert stored.definition.icon == "toy-box"
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
# 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
|
||||
# windows alone, and updates the rule row IN PLACE (same rule id).
|
||||
rescheduled = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"rule": {"rule_type": "daily", "start_date": "2026-04-01"}},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert rescheduled.status_code == 200
|
||||
assert rescheduled.json()["rule"]["rule_type"] == "daily"
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Clear the den"
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("afternoon", None)
|
||||
}
|
||||
assert stored.rule.to_dict() == {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"weekday_set": None,
|
||||
"day_of_month": None,
|
||||
"nth_weekday": None,
|
||||
"nth_weekday_weekday": None,
|
||||
"month": None,
|
||||
"start_date": "2026-04-01",
|
||||
"end_date": None,
|
||||
}
|
||||
record = await dao.get(definition_id)
|
||||
assert record.schedule_rule_id == original_rule_id
|
||||
rule_row = await _dao_rules.ScheduleRulesDao(
|
||||
admin_questdefs.database
|
||||
).get(record.schedule_rule_id)
|
||||
assert rule_row.rule_type == "daily"
|
||||
assert rule_row.start_date == "2026-04-01"
|
||||
|
||||
|
||||
async def test_edit_can_clear_description_and_icon_explicitly(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An explicit null description/icon clears them (the core's edit path).
|
||||
|
||||
Unlike the children edit, the definition edit supports clearing: a
|
||||
supplied null IS forwarded and writes NULL; everything omitted
|
||||
stays untouched.
|
||||
"""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
definition_id = created.json()["id"]
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"description": None, "icon": None},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["description"] is None
|
||||
assert response.json()["icon"] is None
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.description is None
|
||||
assert stored.definition.icon is None
|
||||
assert stored.definition.title == "Tidy the den"
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
|
||||
|
||||
async def test_edit_unknown_definition_is_404(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An edit naming a definition that does not exist is 404."""
|
||||
response = await admin_questdefs.client.patch(
|
||||
"/api/v1/admin/quest-definitions/999",
|
||||
json={"title": "Ghost chore"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Quest definition not found"
|
||||
|
||||
|
||||
async def test_edit_rejections_are_422_and_change_nothing(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A rejected rule or window set is 422; the stored state survives.
|
||||
|
||||
The core rejects BEFORE any write (and the DAO edit runs in one
|
||||
transaction), so a 422 never leaves a half-applied change behind.
|
||||
"""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
definition_id = created.json()["id"]
|
||||
|
||||
rejected_bodies: list[dict[str, object]] = [
|
||||
{"rule": {"rule_type": "weekly"}}, # weekly without weekday_set
|
||||
{"rule": None}, # an explicit null rule is rejected by the core
|
||||
{"windows": ["attic"]}, # unknown window name
|
||||
{"windows": None}, # an explicit null window set is rejected
|
||||
{"title": ""}, # the core rejects an empty title
|
||||
]
|
||||
for body in rejected_bodies:
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422, body
|
||||
|
||||
# Database proof: nothing changed for any rejected call.
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.title == "Tidy the den"
|
||||
assert stored.definition.description == (
|
||||
"Put everything back where it belongs."
|
||||
)
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
assert {(w.window, w.due_time) for w in stored.windows} == {
|
||||
("morning", None),
|
||||
("evening", "19:30"),
|
||||
}
|
||||
|
||||
|
||||
@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:
|
||||
"""is_active=false deactivates; the definition STILL exists intact.
|
||||
|
||||
Deactivation is the only removal path: the row, rule, assignees and
|
||||
windows all survive, and reactivation through the same route
|
||||
restores the flag without anything having been lost.
|
||||
"""
|
||||
client = admin_questdefs.client
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
assert created.status_code == 201
|
||||
definition_id = created.json()["id"]
|
||||
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
payload = deactivated.json()
|
||||
assert payload["is_active"] is False
|
||||
# Everything else came back unchanged: deactivation only flips a flag.
|
||||
assert payload["title"] == "Tidy the den"
|
||||
assert payload["rule"] == _weekly_rule_dict()
|
||||
assert [a["id"] for a in payload["assignees"]] == [ada]
|
||||
assert {w["window"]: w["due_time"] for w in payload["windows"]} == {
|
||||
"morning": None,
|
||||
"evening": "19:30",
|
||||
}
|
||||
|
||||
# Database proof: the definition left the ACTIVE list but its row,
|
||||
# rule, assignees and windows were NOT hard-deleted.
|
||||
assert definition_id not in await _definitions_by_id(
|
||||
admin_questdefs.database
|
||||
)
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
record = await dao.get(definition_id)
|
||||
assert record is not None
|
||||
assert record.is_active is False
|
||||
assert record.title == "Tidy the den"
|
||||
assert await dao.list_assignees(definition_id) != []
|
||||
assert await dao.list_windows(definition_id) != []
|
||||
|
||||
# Reactivation goes through the same route: the flag is set, not
|
||||
# one-way, and the surviving rule is still the one that was stored.
|
||||
reactivated = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}/active",
|
||||
json={"is_active": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert reactivated.status_code == 200
|
||||
assert reactivated.json()["is_active"] is True
|
||||
stored = (await _definitions_by_id(admin_questdefs.database))[
|
||||
definition_id
|
||||
]
|
||||
assert stored.definition.is_active is True
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
assert [child.id for child in stored.assignees] == [ada]
|
||||
|
||||
|
||||
async def test_set_active_unknown_definition_is_404(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A set_active naming a definition that does not exist is 404."""
|
||||
response = await admin_questdefs.client.patch(
|
||||
"/api/v1/admin/quest-definitions/999/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Quest definition not found"
|
||||
|
||||
|
||||
async def test_set_active_malformed_body_is_422(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-bool is_active is rejected by the body model (422)."""
|
||||
response = await admin_questdefs.client.patch(
|
||||
"/api/v1/admin/quest-definitions/1/active",
|
||||
json={"is_active": "maybe"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -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,398 +0,0 @@
|
||||
"""Admin-plane settings route tests (task 2b3de7e5).
|
||||
|
||||
These tests exercise the admin plane's settings read and update routes
|
||||
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 — signature, issuer, audience, expiry and the
|
||||
nestquest-admins group — with NO network access. The stored settings
|
||||
document is read back through the SAME core copy the app uses
|
||||
(``nestquest_core.*``; see the two-copy caveat in api/nestquest_core.py)
|
||||
via its :class:`nestquest_core.dao_meta.MetaStateDao`, so persistence is
|
||||
proven against the DATABASE the routes wrote, not a parallel copy.
|
||||
|
||||
Proven per the done-condition, everything read back through the API:
|
||||
|
||||
- ``GET /api/v1/admin/settings`` returns the current effective settings
|
||||
— the ten documented fields at their defaults on a fresh database.
|
||||
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
|
||||
NEXT read reflects them; a later partial update merges onto the
|
||||
current effective settings (omitted fields keep their stored value),
|
||||
and the document is stored under the well-known ``nestquest_meta_state``
|
||||
key.
|
||||
- An invalid value (a non-positive or boolean horizon, a non-strict
|
||||
time, a non-bool toggle, a non-string target) is rejected with 422
|
||||
naming the field, and the stored settings are UNCHANGED — the next
|
||||
read still shows the previous value. An unknown field is rejected
|
||||
the same way, also when mixed with a valid field (validate
|
||||
everything before writing).
|
||||
- A corrupt stored document (invalid JSON, a non-object, an invalid
|
||||
field value) does not crash the service: the read falls back to the
|
||||
defaults, per field where possible.
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: a non-admin JWT is 403 and an absent credential is 401
|
||||
on both routes — and no refused patch writes anything.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The meta-state DAO through the SAME core copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy) —
|
||||
# used for the tests' database assertions only; the routes reach it
|
||||
# through api.nestquest_core.
|
||||
_dao_meta = importlib.import_module("nestquest_core.dao_meta")
|
||||
|
||||
#: The well-known ``nestquest_meta_state`` key the API stores its
|
||||
#: settings under — asserted literally so the contract is pinned here,
|
||||
#: independent of the core constant.
|
||||
SETTINGS_META_KEY = "settings"
|
||||
|
||||
#: The all-defaults effective settings a fresh database reports — the
|
||||
#: documented ten fields, asserted literally so the payload contract is
|
||||
#: pinned here, independent of the core constants.
|
||||
DEFAULT_SETTINGS = {
|
||||
"horizon_days": 14,
|
||||
"day_rollover_time": "00:00",
|
||||
"notify_target": "",
|
||||
"morning_summary_time": "08:00",
|
||||
"afternoon_reminder_time": "15:00",
|
||||
"end_of_day_report_time": "20:00",
|
||||
"morning_summary_enabled": True,
|
||||
"afternoon_reminder_enabled": True,
|
||||
"end_of_day_report_enabled": True,
|
||||
"celebration_enabled": True,
|
||||
}
|
||||
|
||||
|
||||
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 settings_client(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the children tests use (stubbed JWKS, no network);
|
||||
the fixture also exposes the app's ONE database connection so tests
|
||||
can read (or corrupt) the stored settings document itself.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# --- read: the effective settings, defaults on a fresh database --------
|
||||
|
||||
|
||||
async def test_get_settings_returns_defaults_on_a_fresh_database(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""GET returns the ten documented fields at their defaults."""
|
||||
response = await settings_client.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == DEFAULT_SETTINGS
|
||||
|
||||
|
||||
# --- update: persists, and the NEXT read reflects it --------------------
|
||||
|
||||
|
||||
async def test_update_persists_and_the_next_read_reflects_it(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PATCH persists the supplied fields; GET reads them back."""
|
||||
client = settings_client.client
|
||||
changes = {
|
||||
"horizon_days": 21,
|
||||
"day_rollover_time": "03:30",
|
||||
"notify_target": "notify.mobile_app_dads_phone",
|
||||
"celebration_enabled": False,
|
||||
}
|
||||
patched = await client.patch(
|
||||
"/api/v1/admin/settings", json=changes, headers=_admin_headers()
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
expected = {**DEFAULT_SETTINGS, **changes}
|
||||
assert patched.json() == expected
|
||||
|
||||
# The NEXT read reflects the persisted settings...
|
||||
reread = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.status_code == 200
|
||||
assert reread.json() == expected
|
||||
|
||||
# ...and the DATABASE holds the merged document: the complete ten
|
||||
# fields, keyed by field name, under the well-known meta_state key.
|
||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||
SETTINGS_META_KEY
|
||||
)
|
||||
assert raw is not None
|
||||
assert json.loads(raw) == expected
|
||||
|
||||
|
||||
async def test_partial_update_merges_onto_the_current_settings(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A later PATCH changes only its own fields; the rest survive."""
|
||||
client = settings_client.client
|
||||
first = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"horizon_days": 7, "notify_target": " notify.x "},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
# The notify target went through the core's own normalizer: stripped.
|
||||
assert first.json()["horizon_days"] == 7
|
||||
assert first.json()["notify_target"] == "notify.x"
|
||||
|
||||
second = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"morning_summary_time": "07:45"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json() == {
|
||||
**DEFAULT_SETTINGS,
|
||||
"horizon_days": 7,
|
||||
"notify_target": "notify.x",
|
||||
"morning_summary_time": "07:45",
|
||||
}
|
||||
|
||||
reread = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.json() == second.json()
|
||||
|
||||
|
||||
async def test_empty_update_is_a_noop(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PATCHing no fields leaves the effective settings as they are."""
|
||||
client = settings_client.client
|
||||
response = await client.patch(
|
||||
"/api/v1/admin/settings", json={}, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == DEFAULT_SETTINGS
|
||||
|
||||
|
||||
async def test_null_resets_a_field_to_its_default(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A JSON ``null`` maps to the field's default (None-as-absent)."""
|
||||
client = settings_client.client
|
||||
set_horizon = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"horizon_days": 7},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert set_horizon.status_code == 200
|
||||
assert set_horizon.json()["horizon_days"] == 7
|
||||
|
||||
reset = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"horizon_days": None},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert reset.status_code == 200
|
||||
assert reset.json()["horizon_days"] == 14
|
||||
|
||||
reread = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.json()["horizon_days"] == 14
|
||||
|
||||
|
||||
# --- validation: rejected values never change the stored settings -------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changes", "field"),
|
||||
[
|
||||
({"horizon_days": 0}, "horizon_days"),
|
||||
({"horizon_days": -3}, "horizon_days"),
|
||||
# A bool is rejected — the core's guardrail survives the route.
|
||||
({"horizon_days": True}, "horizon_days"),
|
||||
({"horizon_days": "soon"}, "horizon_days"),
|
||||
({"day_rollover_time": "25:00"}, "day_rollover_time"),
|
||||
({"day_rollover_time": "7:30"}, "day_rollover_time"),
|
||||
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
|
||||
({"celebration_enabled": 1}, "celebration_enabled"),
|
||||
({"notify_target": 5}, "notify_target"),
|
||||
],
|
||||
)
|
||||
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
|
||||
settings_client: SimpleNamespace, changes: dict[str, object], field: str
|
||||
) -> None:
|
||||
"""A rejected value is 422 naming the field; the next read is same."""
|
||||
client = settings_client.client
|
||||
seeded = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={
|
||||
"horizon_days": 21,
|
||||
"day_rollover_time": "01:30",
|
||||
"morning_summary_enabled": False,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert seeded.status_code == 200
|
||||
before = seeded.json()
|
||||
|
||||
response = await client.patch(
|
||||
"/api/v1/admin/settings", json=changes, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422, changes
|
||||
assert field in str(response.json()["detail"]), changes
|
||||
|
||||
after = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert after.status_code == 200
|
||||
assert after.json() == before
|
||||
|
||||
|
||||
async def test_unknown_field_is_rejected_and_stored_settings_unchanged(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown field is 422 naming it — alone or beside a valid one."""
|
||||
client = settings_client.client
|
||||
unknown = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"bogus_setting": 1},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown.status_code == 422
|
||||
assert "bogus_setting" in str(unknown.json()["detail"])
|
||||
|
||||
mixed = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"horizon_days": 7, "bogus_setting": 1},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert mixed.status_code == 422
|
||||
assert "bogus_setting" in str(mixed.json()["detail"])
|
||||
|
||||
# Neither rejection wrote anything, not even the valid sibling.
|
||||
after = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert after.json() == DEFAULT_SETTINGS
|
||||
|
||||
|
||||
async def test_non_object_body_is_422(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A body that is not a JSON object is FastAPI's 422 (the shape)."""
|
||||
client = settings_client.client
|
||||
for body in ([1, 2], "nope", 5):
|
||||
response = await client.patch(
|
||||
"/api/v1/admin/settings", json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422, body
|
||||
# A bare JSON null is likewise not an object.
|
||||
response = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
content=b"null",
|
||||
headers={**_admin_headers(), "content-type": "application/json"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
after = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert after.json() == DEFAULT_SETTINGS
|
||||
|
||||
|
||||
# --- resilience: a corrupt stored document does not crash the service ---
|
||||
|
||||
|
||||
async def test_corrupt_stored_document_falls_back_to_defaults(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""GET degrades gracefully over an unusable settings document."""
|
||||
client = settings_client.client
|
||||
dao = _dao_meta.MetaStateDao(settings_client.database)
|
||||
|
||||
# Not JSON at all: all defaults, no crash.
|
||||
await dao.set(SETTINGS_META_KEY, "{not-json")
|
||||
response = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == DEFAULT_SETTINGS
|
||||
|
||||
# JSON but not an object: all defaults, no crash.
|
||||
await dao.set(SETTINGS_META_KEY, json.dumps([1, 2]))
|
||||
response = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == DEFAULT_SETTINGS
|
||||
|
||||
# An object with one invalid field: PER-FIELD fallback — the valid
|
||||
# sibling survives, the invalid one falls back to its default.
|
||||
await dao.set(
|
||||
SETTINGS_META_KEY,
|
||||
json.dumps({"horizon_days": 7, "day_rollover_time": "soon"}),
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {**DEFAULT_SETTINGS, "horizon_days": 7}
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards both routes
|
||||
|
||||
|
||||
async def test_settings_routes_refuse_non_admin_and_absent_credentials(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401; none write."""
|
||||
client = settings_client.client
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
forbidden_get = await client.get(
|
||||
"/api/v1/admin/settings",
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden_get.status_code == 403
|
||||
assert forbidden_get.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
forbidden_patch = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"horizon_days": 7},
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden_patch.status_code == 403
|
||||
|
||||
absent_get = await client.get("/api/v1/admin/settings")
|
||||
assert absent_get.status_code == 401
|
||||
absent_patch = await client.patch(
|
||||
"/api/v1/admin/settings", json={"horizon_days": 7}
|
||||
)
|
||||
assert absent_patch.status_code == 401
|
||||
|
||||
# No refused request changed anything: the settings are untouched.
|
||||
after = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert after.json() == DEFAULT_SETTINGS
|
||||
@@ -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,694 +0,0 @@
|
||||
"""Admin-plane uncomplete and regenerate route tests (task da0226b3).
|
||||
|
||||
These tests exercise the admin plane's completion-reversal and
|
||||
re-materialization routes 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 — signature, issuer, audience, expiry and the
|
||||
nestquest-admins group — 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.*``; the two-copy caveat
|
||||
in api/nestquest_core.py), and the route's ONE clock read
|
||||
(:func:`api.routes_admin._local_now`) is pinned the same way the panel
|
||||
route's is, so seeded rows and route stamps cannot straddle midnight.
|
||||
|
||||
Every done-condition case is proven against the DATABASE, read through
|
||||
that same core copy:
|
||||
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an admin JWT passes, a JWT without the group is 403, an
|
||||
absent credential is 401 — and nothing is written.
|
||||
- ``POST /api/v1/admin/instances/{id}/uncomplete``: the completion
|
||||
events log for the instance holds the ORIGINAL ``completed`` row
|
||||
UNTOUCHED plus ONE appended ``uncompleted`` row attributed to the
|
||||
verified token's ``sub`` (actor_source ``user``), and the instance's
|
||||
DERIVED state is ``open`` again; a second uncomplete is a no-op that
|
||||
appends nothing.
|
||||
- On an ACTUAL reversal the ``nestquest_quest_uncompleted`` transition
|
||||
arrives on the SSE stream exactly once (subscribed live as
|
||||
:mod:`tests.test_api_sse` does); a no-op uncomplete publishes
|
||||
nothing.
|
||||
- ``POST /api/v1/admin/regenerate`` re-materializes without
|
||||
duplicating: running it twice (household, definition and child
|
||||
scopes) leaves the instance count stable, and the scoped variants
|
||||
rebuild only their own scope's rows.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): an unknown
|
||||
instance is 404 (the core's ``instance_id:`` prefix, the same key the
|
||||
panel complete route uses); unknown definition/child scope ids are 404
|
||||
from the route's own existence reads; malformed input — a bad scope,
|
||||
a scope/id mismatch, a zero id, a non-integer path id — is 422 before
|
||||
any handler code runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api import routes_admin
|
||||
from api.app import create_app
|
||||
from api.auth import JwksCache
|
||||
from api.config import ApiConfig
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
AUDIENCE,
|
||||
ISSUER,
|
||||
JWKS_URL,
|
||||
KID,
|
||||
AdminRunner,
|
||||
StubbedFetch,
|
||||
SUBJECT,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
from tests.test_api_panel import PANEL_TOKEN, _seed_household
|
||||
from tests.test_api_sse import _EventStream, _Server, _stream
|
||||
|
||||
# The completion and instance-DAO layers through the SAME core copy the
|
||||
# app uses (nestquest_core.*, NOT the conftest-stubbed
|
||||
# custom_components copy) — used for the tests' database assertions
|
||||
# only; the routes reach them through api.nestquest_core.
|
||||
_completion = importlib.import_module("nestquest_core.completion")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
|
||||
#: The documented quest-uncompleted event name (core const, §3).
|
||||
EVENT_QUEST_UNCOMPLETED = importlib.import_module(
|
||||
"nestquest_core.const"
|
||||
).EVENT_QUEST_UNCOMPLETED
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _pinned_noon() -> tuple[datetime.date, datetime.datetime]:
|
||||
"""One host clock read pinned to 12:00 UTC that day.
|
||||
|
||||
The seed's completion and the route's clock both anchor to this
|
||||
instant, so nothing can straddle midnight mid-test.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
return today, datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
|
||||
)
|
||||
|
||||
|
||||
async def _instance_count(database: object) -> int:
|
||||
"""The whole ``quest_instances`` row count (the sanctioned probe)."""
|
||||
row = await database.fetch_one("SELECT COUNT(*) FROM quest_instances")
|
||||
return row[0]
|
||||
|
||||
|
||||
async def _events_for_instance(
|
||||
database: object, instance_id: int
|
||||
) -> list:
|
||||
"""The instance's completion events, in append order."""
|
||||
dao = _dao_instances.CompletionEventsDao(database)
|
||||
return await dao.list_by_instance(instance_id)
|
||||
|
||||
|
||||
async def _derived_state(
|
||||
database: object, instance_id: int, today: datetime.date
|
||||
) -> str:
|
||||
"""The instance's derived state through the app's core copy."""
|
||||
dao = _dao_instances.QuestInstancesDao(database)
|
||||
instance = await dao.get_by_id(instance_id)
|
||||
assert instance is not None
|
||||
latest = await _dao_instances.CompletionEventsDao(
|
||||
database
|
||||
).get_latest_for_instance(instance_id)
|
||||
return _completion.derive_state(instance, latest, today)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_api(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth and children tests use (stubbed JWKS, no
|
||||
network); the fixture exposes the app's ONE database connection so
|
||||
tests assert the routes' effects on the stored rows themselves.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""A seeded household served by the admin-plane app, clock pinned.
|
||||
|
||||
``_seed_household`` completes Bo's today instance (17:00 due, 12:00
|
||||
pinned — on time), which is the completion the uncomplete tests
|
||||
reverse. The route's ``_local_now`` is pinned to the same instant.
|
||||
"""
|
||||
today, pinned_now = _pinned_noon()
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
monkeypatch.setattr(routes_admin, "_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,
|
||||
database=runner.app.state.db.database,
|
||||
today=today,
|
||||
)
|
||||
|
||||
|
||||
class _AdminServer(_Server):
|
||||
""":mod:`tests.test_api_sse`'s live-uvicorn server, admin-enabled.
|
||||
|
||||
Same ephemeral-port uvicorn thread (httpx's ASGI transport buffers
|
||||
SSE bodies, so a live server is the only way to stream), but the
|
||||
app factory swaps the JWKS cache for one built around the harness's
|
||||
stub fetcher, so a real admin JWT verifies with NO network access.
|
||||
"""
|
||||
|
||||
def _make_app_factory(self, db_path: str):
|
||||
"""The uvicorn app factory with the stubbed JWKS cache."""
|
||||
|
||||
def factory():
|
||||
app = create_app(
|
||||
ApiConfig(
|
||||
db_path=db_path,
|
||||
panel_token=PANEL_TOKEN,
|
||||
oidc_issuer=ISSUER,
|
||||
oidc_audience=AUDIENCE,
|
||||
oidc_jwks_url=JWKS_URL,
|
||||
)
|
||||
)
|
||||
app.state.jwks_cache = JwksCache(
|
||||
JWKS_URL, fetcher=StubbedFetch(jwks_for(ADMIN_KEY, KID))
|
||||
)
|
||||
self._server_loop = asyncio.get_running_loop()
|
||||
self.app = app
|
||||
return app
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_sse(temp_db_path: str) -> SimpleNamespace:
|
||||
"""A seeded household served by a live uvicorn app, clock pinned.
|
||||
|
||||
Same pinning discipline as the panel tests, applied to
|
||||
:func:`api.routes_admin._local_now` BEFORE the server starts (the
|
||||
app is created inside uvicorn's factory thread).
|
||||
"""
|
||||
today, pinned_now = _pinned_noon()
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
original = routes_admin._local_now
|
||||
routes_admin._local_now = lambda: pinned_now
|
||||
try:
|
||||
with _AdminServer(temp_db_path) as server:
|
||||
client = httpx.AsyncClient(base_url=server.url(""))
|
||||
async with client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
seed=seed,
|
||||
server=server,
|
||||
database=server.app.state.db.database,
|
||||
today=today,
|
||||
)
|
||||
finally:
|
||||
routes_admin._local_now = original
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards every route ---
|
||||
|
||||
|
||||
async def test_uncomplete_and_regenerate_refuse_non_admin_and_absent(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
events_before = await _events_for_instance(database, brush.id)
|
||||
instances_before = await _instance_count(database)
|
||||
|
||||
uncomplete_url = f"/api/v1/admin/instances/{brush.id}/uncomplete"
|
||||
forbidden = await client.post(
|
||||
uncomplete_url,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
assert forbidden.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
unauthenticated = await client.post(uncomplete_url)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
regenerate_bodies = (
|
||||
{},
|
||||
{"scope": "household"},
|
||||
{"scope": "child", "child_id": 1},
|
||||
)
|
||||
for body in regenerate_bodies:
|
||||
forbidden = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin}"},
|
||||
)
|
||||
assert forbidden.status_code == 403, body
|
||||
unauthenticated = await client.post(
|
||||
"/api/v1/admin/regenerate", json=body
|
||||
)
|
||||
assert unauthenticated.status_code == 401, body
|
||||
|
||||
# No handler ran: the seeded completion is still the ONLY event for
|
||||
# the instance and the instance table is untouched.
|
||||
assert (
|
||||
await _events_for_instance(database, brush.id) == events_before
|
||||
)
|
||||
assert await _instance_count(database) == instances_before
|
||||
|
||||
|
||||
# --- uncomplete: the append-only reversal -----------------------------------
|
||||
|
||||
|
||||
async def test_uncomplete_appends_reversal_and_derived_state_is_open(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A reversal APPENDS an uncompleted event; the instance is open again.
|
||||
|
||||
The original completed row is proven byte-identical after the call
|
||||
(frozen-dataclass equality), the new row carries the verified
|
||||
token's ``sub`` as the user actor, and the DERIVED state —
|
||||
recomputed from the latest event — is ``open`` again.
|
||||
"""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
today = admin_household.today
|
||||
|
||||
before = await _events_for_instance(database, brush.id)
|
||||
assert [event.event_type for event in before] == ["completed"]
|
||||
assert await _derived_state(database, brush.id, today) == "done"
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {
|
||||
"instance_id": brush.id,
|
||||
"state": "open",
|
||||
"appended": True,
|
||||
}
|
||||
|
||||
events = await _events_for_instance(database, brush.id)
|
||||
assert len(events) == 2
|
||||
# The original completed row is UNTOUCHED — never edited, never
|
||||
# deleted (append-only completion_events).
|
||||
assert events[0] == before[0]
|
||||
assert events[0].event_type == "completed"
|
||||
assert events[0].actor_source == "panel"
|
||||
# The reversal is the NEW row, attributed to the verified token.
|
||||
reversal = events[1]
|
||||
assert reversal.event_type == "uncompleted"
|
||||
assert reversal.instance_id == brush.id
|
||||
assert reversal.child_id == brush.child_id
|
||||
assert reversal.actor_source == "user"
|
||||
assert reversal.actor_user_id == SUBJECT
|
||||
assert reversal.actor_child_id is None
|
||||
assert reversal.was_on_time is None
|
||||
# The derived state is recomputed from the latest event: open again.
|
||||
assert await _derived_state(database, brush.id, today) == "open"
|
||||
|
||||
|
||||
async def test_second_uncomplete_is_a_noop_that_appends_nothing(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A repeat uncomplete appends NOTHING and still succeeds."""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
today = admin_household.today
|
||||
|
||||
first = await client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.json()["appended"] is True
|
||||
|
||||
second = await client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
body = second.json()
|
||||
assert body["appended"] is False
|
||||
assert body["state"] == "open"
|
||||
|
||||
events = await _events_for_instance(database, brush.id)
|
||||
assert len(events) == 2
|
||||
assert [event.event_type for event in events] == [
|
||||
"completed",
|
||||
"uncompleted",
|
||||
]
|
||||
assert await _derived_state(database, brush.id, today) == "open"
|
||||
|
||||
|
||||
async def test_uncomplete_unknown_instance_is_404_and_malformed_is_422(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown instance is 404; a non-integer path id is 422."""
|
||||
client = admin_household.client
|
||||
brush = admin_household.seed.brush_teeth_instance
|
||||
before = await _events_for_instance(
|
||||
admin_household.database, brush.id
|
||||
)
|
||||
|
||||
unknown = await client.post(
|
||||
"/api/v1/admin/instances/999/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown.status_code == 404
|
||||
assert unknown.json()["detail"] == "Quest instance not found"
|
||||
|
||||
malformed = await client.post(
|
||||
"/api/v1/admin/instances/not-an-int/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert malformed.status_code == 422
|
||||
|
||||
# Neither rejection wrote anything.
|
||||
assert (
|
||||
await _events_for_instance(admin_household.database, brush.id)
|
||||
== before
|
||||
)
|
||||
|
||||
|
||||
async def test_uncomplete_already_open_past_due_reports_missed(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A no-op uncomplete still reports the honest DERIVED state.
|
||||
|
||||
The seeded stale instance is open and past due: uncompleting it is
|
||||
a no-op (nothing appended) and the derived state is ``missed`` —
|
||||
open-but-past-due, never an event type.
|
||||
"""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
stale = admin_household.seed.stale_instance
|
||||
today = admin_household.today
|
||||
assert await _derived_state(database, stale.id, today) == "missed"
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/admin/instances/{stale.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["appended"] is False
|
||||
assert body["state"] == "missed"
|
||||
|
||||
# The no-op appended nothing: the instance still has NO events.
|
||||
assert await _events_for_instance(database, stale.id) == []
|
||||
|
||||
|
||||
# --- the SSE transition on an actual reversal --------------------------------
|
||||
|
||||
|
||||
async def test_uncomplete_publishes_exactly_one_uncompleted_transition(
|
||||
admin_sse: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An actual reversal streams ONE quest-uncompleted event; a no-op none.
|
||||
|
||||
The stream is subscribed FIRST (test_api_sse's live-server reader),
|
||||
the completed instance is reversed through the admin route, and the
|
||||
documented payload fields are asserted; the no-op second call adds
|
||||
no frame.
|
||||
"""
|
||||
seed = admin_sse.seed
|
||||
brush = seed.brush_teeth_instance
|
||||
stream = _stream(admin_sse.server.url("/api/v1/panel/events"))
|
||||
async with stream:
|
||||
first = await admin_sse.client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.json()["appended"] is True
|
||||
|
||||
event_type, payload = await stream.next_event()
|
||||
assert event_type == EVENT_QUEST_UNCOMPLETED
|
||||
assert event_type == "nestquest_quest_uncompleted"
|
||||
assert payload["child_id"] == seed.bo.id
|
||||
assert payload["child_name"] == "Bo"
|
||||
assert payload["instance_id"] == brush.id
|
||||
assert payload["quest_title"] == "Brush teeth"
|
||||
assert payload["window"] == "morning"
|
||||
assert payload["due_date"] == brush.due_date
|
||||
assert payload["due_time"] == "17:00"
|
||||
assert payload["occurred_at"] # strict UTC ISO-8601 stamp
|
||||
assert "was_on_time" not in payload
|
||||
|
||||
# The no-op second call publishes nothing: the stream stays
|
||||
# silent past the window.
|
||||
second = await admin_sse.client.post(
|
||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["appended"] is False
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(stream.queue.get(), timeout=0.3)
|
||||
|
||||
|
||||
# --- regenerate: re-materialize without duplicating --------------------------
|
||||
|
||||
|
||||
async def _create_child(client: object, name: str) -> int:
|
||||
"""Create one child through the admin children route."""
|
||||
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_daily_definition(
|
||||
client: object, assignee_ids: list[int]
|
||||
) -> int:
|
||||
"""Create one daily one-window definition for the given assignees."""
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": {"rule_type": "daily", "start_date": "2026-03-02"},
|
||||
"assignee_child_ids": assignee_ids,
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
async def test_regenerate_household_never_duplicates(
|
||||
admin_household: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Two household regenerations leave the instance count stable.
|
||||
|
||||
The first call re-materializes the rolling horizon
|
||||
``[today, today + DEFAULT_HORIZON_DAYS]``; the second walks the
|
||||
SAME tuples and upserts them in place — the row count never grows
|
||||
and the core reports the same upsert count both times.
|
||||
"""
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
|
||||
before = await _instance_count(database)
|
||||
assert before > 0 # the seed already materialized a few days
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
)
|
||||
assert first.status_code == 200
|
||||
body = first.json()
|
||||
assert body["scope"] == "household"
|
||||
assert body["definition_id"] is None
|
||||
assert body["child_id"] is None
|
||||
assert body["count"] >= before
|
||||
after_first = await _instance_count(database)
|
||||
assert after_first >= before
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["count"] == body["count"]
|
||||
# The idempotent walk: the second run's row count is EXACTLY the
|
||||
# first's — no duplicated instances.
|
||||
assert await _instance_count(database) == after_first
|
||||
|
||||
|
||||
async def test_regenerate_definition_scope_rematerializes(
|
||||
admin_api: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The definition scope rebuilds that definition's horizon, twice, stably.
|
||||
|
||||
Creating a definition through the admin route materializes NOTHING
|
||||
(the core's create path does not regenerate); the scoped regenerate
|
||||
call is what materializes its instances, and re-running it leaves
|
||||
the row count exactly where the first run left it.
|
||||
"""
|
||||
client = admin_api.client
|
||||
database = admin_api.database
|
||||
ada = await _create_child(client, "Ada")
|
||||
definition_id = await _create_daily_definition(client, [ada])
|
||||
|
||||
assert await _instance_count(database) == 0
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "definition", "definition_id": definition_id},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
body = first.json()
|
||||
assert body["scope"] == "definition"
|
||||
assert body["definition_id"] == definition_id
|
||||
assert body["child_id"] is None
|
||||
assert body["count"] > 0
|
||||
after_first = await _instance_count(database)
|
||||
assert after_first == body["count"]
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "definition", "definition_id": definition_id},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["count"] == body["count"]
|
||||
assert await _instance_count(database) == after_first
|
||||
|
||||
|
||||
async def test_regenerate_child_scope_touches_only_that_child(
|
||||
admin_api: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The child scope rebuilds that child's rows and no others'.
|
||||
|
||||
Two children share one daily definition; the household walk
|
||||
materializes both. A child-scoped regenerate for Ada re-runs her
|
||||
side of the walk (same count) while Bo's rows keep their identity,
|
||||
and re-running it never grows the table.
|
||||
"""
|
||||
client = admin_api.client
|
||||
database = admin_api.database
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
ada = await _create_child(client, "Ada")
|
||||
bo = await _create_child(client, "Bo")
|
||||
# One daily definition assigned to BOTH children: the household walk
|
||||
# gives each child its own rows.
|
||||
await _create_daily_definition(client, [ada, bo])
|
||||
|
||||
# Materialize the household first so BOTH children have rows.
|
||||
seeded = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
)
|
||||
assert seeded.status_code == 200
|
||||
assert seeded.json()["scope"] == "household"
|
||||
|
||||
ada_rows = await instances.list_by_date_range(
|
||||
ada, "2026-03-02", "2100-01-01"
|
||||
)
|
||||
bo_rows = await instances.list_by_date_range(
|
||||
bo, "2026-03-02", "2100-01-01"
|
||||
)
|
||||
assert ada_rows and bo_rows
|
||||
total = await _instance_count(database)
|
||||
assert total == len(ada_rows) + len(bo_rows)
|
||||
|
||||
for expected_count in (len(ada_rows), len(ada_rows)):
|
||||
response = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "child", "child_id": ada},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {
|
||||
"scope": "child",
|
||||
"definition_id": None,
|
||||
"child_id": ada,
|
||||
"count": expected_count,
|
||||
}
|
||||
# Ada's side re-materialized the same number of rows and Bo's
|
||||
# rows kept their identity; the table never grew.
|
||||
assert await instances.list_by_date_range(
|
||||
ada, "2026-03-02", "2100-01-01"
|
||||
)
|
||||
assert await instances.list_by_date_range(
|
||||
bo, "2026-03-02", "2100-01-01"
|
||||
) == bo_rows
|
||||
assert await _instance_count(database) == total
|
||||
|
||||
|
||||
async def test_regenerate_unknown_scope_ids_are_404(
|
||||
admin_api: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown definition or child scope id is 404, nothing written."""
|
||||
client = admin_api.client
|
||||
|
||||
unknown_definition = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "definition", "definition_id": 999},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown_definition.status_code == 404
|
||||
assert unknown_definition.json()["detail"] == "Quest definition not found"
|
||||
|
||||
unknown_child = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
json={"scope": "child", "child_id": 999},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert unknown_child.status_code == 404
|
||||
assert unknown_child.json()["detail"] == "Child not found"
|
||||
|
||||
assert await _instance_count(admin_api.database) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{"scope": "galaxy"}, # unknown scope
|
||||
{"scope": "definition"}, # scope without its id
|
||||
{"scope": "child"}, # scope without its id
|
||||
{"scope": "household", "child_id": 1}, # id without its scope
|
||||
{"scope": "household", "definition_id": 1}, # id without its scope
|
||||
{
|
||||
"scope": "definition",
|
||||
"definition_id": 1,
|
||||
"child_id": 2,
|
||||
}, # scope mismatch
|
||||
{"scope": "child", "child_id": 0}, # zero id
|
||||
{"scope": "child", "child_id": "seven"}, # wrong type
|
||||
],
|
||||
)
|
||||
async def test_regenerate_malformed_bodies_are_422(
|
||||
admin_api: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""Malformed scope/id combinations are 422 before any core call."""
|
||||
response = await admin_api.client.post(
|
||||
"/api/v1/admin/regenerate", json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422, body
|
||||
assert await _instance_count(admin_api.database) == 0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user