Compare commits
157
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76d1970625 | ||
|
|
4da762173c | ||
|
|
ca477125d6 | ||
|
|
7fec3c76ef | ||
|
|
f71124c3ed | ||
|
|
e78edbae3b | ||
|
|
b3715ccdcd | ||
|
|
315010b901 | ||
|
|
1503774e85 | ||
|
|
87757aaaf9 | ||
|
|
3ee4c99e5f | ||
|
|
d4ea552ad7 | ||
|
|
4e52e8dec8 | ||
|
|
ee1fae984a | ||
|
|
418fc69f36 | ||
|
|
b6b1ba65b4 | ||
|
|
37f7d25cd6 | ||
|
|
fe71d37bb0 | ||
|
|
1c08c793e3 | ||
|
|
7386999cf7 | ||
|
|
43ad44ee48 | ||
|
|
4cb07fbca4 | ||
|
|
030ba83b14 | ||
|
|
a2c027deca | ||
|
|
2b45ebf035 | ||
|
|
028215fa38 | ||
|
|
0d2e0d6f26 | ||
|
|
1911b2bf6e | ||
|
|
89f517b3ad | ||
|
|
edca25affa | ||
|
|
7c62c50526 | ||
|
|
ca93176768 | ||
|
|
e508c80465 | ||
|
|
3a2e6faa69 | ||
|
|
5092369d49 | ||
|
|
f6166f25d9 | ||
|
|
bf84793a1c | ||
|
|
9d3cb30617 | ||
|
|
b913772007 | ||
|
|
d2f9c50930 | ||
|
|
19a17b647d | ||
|
|
715aac994a | ||
|
|
ad6f1768d7 | ||
|
|
fcc7a29f05 | ||
|
|
f31bda2714 | ||
|
|
dc7a82f6e2 | ||
|
|
1d5143f850 | ||
|
|
fbde3eeaa1 | ||
|
|
3c832c1cee | ||
|
|
8c421abeb2 | ||
|
|
8cd43141a9 | ||
|
|
3de9f4b970 | ||
|
|
25005cda77 | ||
|
|
35e66bd9e6 | ||
|
|
a65e32f577 | ||
|
|
36263d3f19 | ||
|
|
58cd68354f | ||
|
|
a9d8f1d225 | ||
|
|
933e29ae76 | ||
|
|
a5dbae5d50 | ||
|
|
ad56d13c6e | ||
|
|
b90f55f750 | ||
|
|
2a4eec658f | ||
|
|
fe38e5040e | ||
|
|
fa153059cb | ||
|
|
5bca24910a | ||
|
|
99a8a133e2 | ||
|
|
df4c9eb21a | ||
|
|
236e8f4adf | ||
|
|
1f7a7ba46c | ||
|
|
813055a24c | ||
|
|
7a0c8d1885 | ||
|
|
00409a76db | ||
|
|
322038998b | ||
|
|
5c99987e7f | ||
|
|
979eafc5a2 | ||
|
|
3ac1d32e1b | ||
|
|
fc81b4b813 | ||
|
|
c1efd39ce3 | ||
|
|
cf67e9bed9 | ||
|
|
b5058a3bed | ||
|
|
eb4de7b565 | ||
|
|
d0f49b44b5 | ||
|
|
2a0e568940 | ||
|
|
37d7b7e6c3 | ||
|
|
dd7ea10936 | ||
|
|
4281b9d01c | ||
|
|
a2e9bdffee | ||
|
|
b1c42f10d0 | ||
|
|
2d30f0ce77 | ||
|
|
a6d9ec927b | ||
|
|
bcdf03dac7 | ||
|
|
52cbf33f33 | ||
|
|
c4fb2fbac5 | ||
|
|
0820544fce | ||
|
|
318d77f443 | ||
|
|
2ec016ebfe | ||
|
|
c3495bb277 | ||
|
|
775a9906e7 | ||
|
|
293efe9705 | ||
|
|
22797cbc7e | ||
|
|
743703e943 | ||
|
|
6e32761b0c | ||
|
|
3add2d2f48 | ||
|
|
c001aca62d | ||
|
|
353d88e3af | ||
|
|
f6aad9d20f | ||
|
|
5706d8b7a6 | ||
|
|
be6367b600 | ||
|
|
ca26ced59f | ||
|
|
2146531665 | ||
|
|
168bb7b2f1 | ||
|
|
4544f78390 | ||
|
|
9836a6f524 | ||
|
|
d6823a54bc | ||
|
|
fa40e21dc2 | ||
|
|
e60a9892ff | ||
|
|
1432dba74c | ||
|
|
1b6973efd3 | ||
|
|
4606d7ddd0 | ||
|
|
802258d431 | ||
|
|
43a7a722b9 | ||
|
|
b2f55ffb90 | ||
|
|
cdfb681f46 | ||
|
|
0637245d33 | ||
|
|
c157f6cb9e | ||
|
|
21948fdee4 | ||
|
|
8c99024dd0 | ||
|
|
b4c9ad35c3 | ||
|
|
4230c1d0d4 | ||
|
|
5a962e0ed7 | ||
|
|
db4f695541 | ||
|
|
d81f38528a | ||
|
|
23a52707af | ||
|
|
3b638cae6f | ||
|
|
ceb32074e7 | ||
|
|
a30bafcb68 | ||
|
|
c90892f082 | ||
|
|
1e1444d1a0 | ||
|
|
c6203327bd | ||
|
|
0db7d4d519 | ||
|
|
353f01e98c | ||
|
|
12bb4d58b4 | ||
|
|
29c058a045 | ||
|
|
c27ed24020 | ||
|
|
43b20f2b12 | ||
|
|
2c8c90a24e | ||
|
|
e98642e236 | ||
|
|
01905ec9ee | ||
|
|
30748f786a | ||
|
|
3dae2eadd7 | ||
|
|
eaf2e45e9a | ||
|
|
5e65cde078 | ||
|
|
748c8242c3 | ||
|
|
0f4b53fd4d | ||
|
|
22f8b5dc55 | ||
|
|
cb2be413bd |
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.metadata.json
|
||||
frontend/node_modules/
|
||||
|
||||
@@ -13,14 +13,14 @@ NestQuest helps households coordinate daily life, chore tracking, custody schedu
|
||||
1. Ensure [HACS (Home Assistant Community Store)](https://hacs.xyz/) is installed.
|
||||
2. In Home Assistant, open **HACS** > **Integrations**.
|
||||
3. Select the three-dots menu in the top right and click **Custom repositories**.
|
||||
4. Enter the repository URL: `https://github.com/talon2king/NestQuest`
|
||||
4. Enter the repository URL: `https://github.com/Maniacs-Mansion/NestQuest`
|
||||
5. Select category **Integration** and click **Add**.
|
||||
6. Search for **NestQuest** and click **Download**.
|
||||
7. Restart Home Assistant.
|
||||
|
||||
> **Note:** Development happens in the private Gitea repository
|
||||
> (`https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`). The GitHub
|
||||
> repository is a public mirror maintained for HACS distribution.
|
||||
> repository is a private mirror maintained for HACS distribution.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
@@ -38,6 +38,202 @@ After installation and restart:
|
||||
2. Click **Add Integration** and search for **NestQuest**.
|
||||
3. Follow the on-screen configuration flow.
|
||||
|
||||
## Events
|
||||
|
||||
NestQuest fires Home Assistant bus events so automations can react to a
|
||||
child's progress. Each payload carries `child_id`, `child_name`,
|
||||
`instance_id`, `quest_title`, the instance's `window`, `due_date`,
|
||||
`due_time`, and `occurred_at` (a strict UTC ISO-8601 timestamp).
|
||||
|
||||
| Event | Fires when | Extra payload fields |
|
||||
|---|---|---|
|
||||
| `nestquest_quest_completed` | a quest is completed (an actual completion, never a re-complete no-op) | `was_on_time` (bool) |
|
||||
| `nestquest_quest_uncompleted` | an admin reverses a completion | — |
|
||||
| `nestquest_child_day_complete` | a completion clears the child's whole day (quests were owed and none remain; a zero-quest day never fires it) | `quests_due`, `quests_completed` |
|
||||
| `nestquest_quest_missed` | the nightly sweep marks a still-open past-due quest missed (integrated in a later release) | — |
|
||||
|
||||
Example automation trigger:
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: nestquest_child_day_complete
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.event.data.child_name == 'Declan' }}"
|
||||
actions:
|
||||
- action: notify.mobile_app_declans_phone
|
||||
data:
|
||||
message: "Every quest cleared — legendary!"
|
||||
```
|
||||
|
||||
## Automation Blueprints
|
||||
|
||||
NestQuest ships four automation blueprints in
|
||||
`custom_components/nestquest/blueprints/automation/`. Import them in
|
||||
Home Assistant under **Settings → Automations & Scenes → Blueprints**
|
||||
(or place the files in your `config/blueprints/automation/` folder),
|
||||
then create one automation per blueprint, filling the inputs with your
|
||||
household's choices.
|
||||
|
||||
### Wiring to the integration's notification settings
|
||||
|
||||
The integration's options flow records your notification choices —
|
||||
notify target, morning/afternoon/end-of-day times, and a toggle per
|
||||
automation. The blueprints take their own inputs at import time;
|
||||
fill them from the same values you stored in the options flow (the
|
||||
integration does not push its settings into imported automations):
|
||||
|
||||
| Blueprint input | Options-flow counterpart |
|
||||
|---|---|
|
||||
| `morning_time` (default 08:00) | `morning_summary_time` |
|
||||
| `afternoon_time` (default 15:00) | `afternoon_reminder_time` |
|
||||
| `report_time` (default 20:00) | `end_of_day_report_time` |
|
||||
| `send_summary` / `send_reminder` / `send_report` / `celebrate` | the four `*_enabled` toggles |
|
||||
| `notify_target` | `notify_target` (your notify service, e.g. `notify.mobile_app_your_phone`) |
|
||||
|
||||
The end-of-day report deliberately runs BEFORE the midnight missed
|
||||
sweep and reads the same day's data — do not schedule it at the
|
||||
day-rollover time.
|
||||
|
||||
### The four blueprints
|
||||
|
||||
1. **Morning summary** (`morning_summary.yaml`) — at the configured
|
||||
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
|
||||
is on and what they owe from `sensor.nestquest_<child>_quests_remaining_today`.
|
||||
Children not present today are omitted entirely.
|
||||
|
||||
2. **Afternoon reminder** (`afternoon_reminder.yaml`) — at the
|
||||
configured time, notifies only about present children who still
|
||||
have open quests. Sends nothing at all when every present child is
|
||||
clear.
|
||||
|
||||
3. **End-of-day report** (`end_of_day_report.yaml`) — one message per
|
||||
evening: each present child's completion count and the quests they
|
||||
still owe (the ones the midnight sweep will mark
|
||||
`nestquest_quest_missed`). Absent children are omitted. Sends
|
||||
nothing on an all-clear day unless `send_even_when_clear` is on.
|
||||
|
||||
4. **Day-complete celebration** (`day_complete_celebration.yaml`) —
|
||||
triggers on the `nestquest_child_day_complete` event (which only
|
||||
fires when quests were owed and all are complete, so a zero-quest
|
||||
day never celebrates). Create one `input_text` helper
|
||||
(`nestquest_last_celebrated`) for the dedupe: the automation
|
||||
records each celebrated child as `<child_id>:<today>` and
|
||||
celebrates each child at most once per day — a re-cleared day does
|
||||
not re-fire, but a second child clearing the same day still
|
||||
celebrates.
|
||||
|
||||
### Example package
|
||||
|
||||
```yaml
|
||||
# One automation per blueprint — Settings → Automations & Scenes →
|
||||
# Create → Blueprint, then pick each NestQuest blueprint.
|
||||
#
|
||||
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
|
||||
# afternoon_reminder: afternoon_time 15:00, same notify target
|
||||
# end_of_day_report: report_time 20:00, send_even_when_clear false, same target
|
||||
# day_complete_celebration: input_text.nestquest_last_celebrated,
|
||||
# celebration_message "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
#
|
||||
# Every blueprint has its own send/celebrate toggle input; turn an
|
||||
# automation off entirely from HA's automation menu.
|
||||
```
|
||||
|
||||
### Events the automations rely on
|
||||
|
||||
| Event | Payload |
|
||||
|---|---|
|
||||
| `nestquest_quest_completed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (UTC ISO-8601), was_on_time |
|
||||
| `nestquest_quest_uncompleted` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at |
|
||||
| `nestquest_child_day_complete` | child_id, child_name, quests_due, quests_completed, occurred_at |
|
||||
| `nestquest_quest_missed` | child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at (fired by the nightly sweep) |
|
||||
|
||||
Sensors the automations read: `sensor.nestquest_<child>_quests_due_today`
|
||||
(attributes: `child_id`, `child_name`, `present`, `instances`,
|
||||
`admin_instances`), `..._quests_remaining_today`,
|
||||
`..._quests_completed_today`, and
|
||||
`binary_sensor.nestquest_<child>_present_today`.
|
||||
|
||||
## TouchHub setup
|
||||
|
||||
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.
|
||||
|
||||
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`,
|
||||
`update_quest_definition`, `set_quest_definition_active`,
|
||||
`set_presence_pattern`, `create_presence_override`,
|
||||
`delete_presence_override`, `export_history_csv`, and `manage_child`
|
||||
(plus `regenerate` flows) — it does not make the account view-only. The
|
||||
kiosk user *can* call `nestquest.complete_quest`, the one open NestQuest
|
||||
service, to complete quests from the panel, and as a non-admin Home
|
||||
Assistant user it may also reach other permitted Home Assistant entities
|
||||
and services.
|
||||
|
||||
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):
|
||||
|
||||
- **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.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
@@ -55,6 +251,28 @@ To set up the development environment and install dependencies:
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Frontend cards
|
||||
|
||||
The Lovelace cards live in `frontend/` (TypeScript + Lit) and build to a
|
||||
single bundle at `custom_components/nestquest/www/nestquest-cards.js`. The
|
||||
integration registers that bundle as a Lovelace resource automatically on
|
||||
setup — do not add it by hand. Card YAML is documented in
|
||||
`design/ENTITIES-AND-SERVICES.md` §4.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Kids panel layout
|
||||
|
||||
The Lovelace panel cards are designed for the wall display's confirmed
|
||||
resolution, 1920×1080 landscape, with no page scroll. TouchHub's bottom
|
||||
dock overlays the NestQuest weather dock strip until it auto-hides, so
|
||||
no tappable NestQuest control sits in that strip (the dock carries
|
||||
`pointer-events: none`). Keep it that way when editing the cards.
|
||||
|
||||
### Running Tests
|
||||
|
||||
Run the test suite with `pytest`:
|
||||
|
||||
+106
-1
@@ -1,5 +1,110 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **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
|
||||
|
||||
### Scope
|
||||
|
||||
Fifth release of NestQuest, promoting Features 06, 07, and 08 from `dev` onto 0.4.0 (quest model, children registry, presence engine):
|
||||
|
||||
- **Feature 06 — quest definitions and assignment:** business layer `quest_definitions.py` over the multi-assignee / multi-window model (D-008). Create/edit/deactivate definitions; assign/unassign children; query helpers for materialization. `ScheduleRule` ⇄ `schedule_rules` storage mapping in the DAO layer.
|
||||
- **Feature 07 — instance materialization:** `materialize.py` fans out one instance per (definition, child, window, firing date) when the recurrence rule fires and the child is present. Idempotent on `(definition_id, child_id, due_date, window)`; skips completed instances; never generates past dates. HA-local `today` and configured `horizon_days` threaded end-to-end. Regeneration on definition/assignee/presence changes; daily rollover listener; startup backfill; `nestquest.regenerate` service.
|
||||
- **Feature 08 — completion and event log:** business layer `completion.py` wrapping the existing append-only `CompletionEventsDao`. `complete_instance` / `uncomplete_instance` (reversal event, original row untouched); state derived from the latest event; HA-local `was_on_time`; derived `missed` (never stored). Actor shapes `'user'` / `'panel'` with `actor_child_id` (D-008). Permission gating of un-complete is Feature 09.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- None at the schema layer. Schema remains **version 6** (advanced in 0.4.0). This release adds application code only.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- No permission decorator yet (Feature 09): `complete_instance` / `uncomplete_instance` are Python APIs, not gated HA services. The only HA service in this release is `nestquest.regenerate`.
|
||||
- No entity or event surface (Feature 10): no sensors, no `nestquest_quest_completed` bus events.
|
||||
- No panel or admin Lovelace cards (Features 12/13).
|
||||
- Un-complete is implemented but not admin-gated until Feature 09.
|
||||
- Single-instance only.
|
||||
|
||||
### Rollback
|
||||
|
||||
Schema version is unchanged from 0.4.0 (still 6). A code downgrade to 0.4.0 does not require a database restore for schema compatibility, but **back up `nestquest.db` (plus `-wal`/`-shm`) before upgrading** — generated instances and completion events written by 0.5.0 remain in the file after downgrade; 0.4.0 will not generate or complete them.
|
||||
|
||||
**HACS installs:**
|
||||
|
||||
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.4.0).
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Manual installs:**
|
||||
|
||||
1. Replace `custom_components/nestquest/` with the 0.4.0 tree.
|
||||
2. Restart Home Assistant.
|
||||
|
||||
**Repository operators:** git-revert the 0.5.0 release merge on `main`.
|
||||
|
||||
## Version 0.4.0 — 2026-09-15
|
||||
|
||||
### Scope
|
||||
|
||||
Fourth release of NestQuest, shipping three completed features on top of the data layer and recurrence engine:
|
||||
|
||||
- **Feature 14 — quest domain model (BREAKING rename, D-007/D-008):** `task_definitions`/`task_instances` are now `quest_definitions`/`quest_instances` everywhere; definitions are multi-assignee (`quest_definition_assignees`, no single child column) and multi-window (`quest_definition_windows` — morning/afternoon/evening, clock ranges in `const.py`); the instance key widens to `(definition_id, child_id, due_date, window)` so twice-daily and shared quests materialise one instance per child per window per day; `completion_events` gains `actor_child_id` (the tapped panel profile; admin events stay NULL) with a second actor-pair CHECK. Schema version advances to **6**.
|
||||
- **Feature 03 — children registry & admin allowlist:** typed business layer for child profiles (create/edit with trimmed required names and duplicate-name warnings, whole-table reorder, deactivation as the only removal path — history is never touched); the admin allowlist with a fail-closed `is_admin` resolver (empty allowlist denies everyone; the last admin cannot be removed); automatic seeding on first setup (the config-flow user is persisted in entry data, with the options-flow list and HA owner accounts as fallbacks) so the owner is never locked out; the options flow gains a multi-select picker of existing Home Assistant users.
|
||||
- **Feature 05 — presence & custody engine:** a pure `PresenceSchedule`/`PresenceOverride` model with total validation and lossless round-trips; anchor-date cycle arithmetic (D-004 — never ISO week numbers or parity); `PresenceEngine.is_present` with the always-present default for schedule-free children; date overrides beat the repeating pattern; a `next_present_dates` preview for the admin schedule UI; a full-year regression guard walking 2020 (366 days, ISO week 53) and 2015 (365 days, ISO week 53) proving the rotation never inverts at a year boundary.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Minimum Home Assistant version: **2024.6.0**.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- The database schema advances from version 1 to version 6. Upgrades are automatic and data-preserving (legacy `task_*` tables are renamed; the definitions table is rebuilt for multi-assignee; legacy instances pin to the morning window; panel events backfill `actor_child_id`). **Back up `nestquest.db` before upgrading** — see Rollback.
|
||||
- Dev installs from before 2026-09-15 migrate automatically on first start.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Still no entities, services, calendar platforms, or panel cards: nothing is user-visible in Home Assistant yet (Features 06-10 land the business layer, materialisation, permission gate, and entity surface).
|
||||
- No materialisation yet: instances are not generated from definitions + presence yet (Feature 07).
|
||||
- Single-instance only.
|
||||
|
||||
### Rollback
|
||||
|
||||
**Back up `nestquest.db` (plus `-wal`/`-shm` sidecars) BEFORE upgrading.** This release migrates the database to schema version 6 with renamed tables; an older NestQuest build refuses a version-6 database ("newer than this integration understands") and cannot read renamed tables at all — a plain downgrade is NOT possible once the database has been migrated.
|
||||
|
||||
**Rollback procedure (after a migrated database):**
|
||||
|
||||
1. Stop Home Assistant.
|
||||
2. Restore the pre-upgrade `nestquest.db` backup (and sidecars) you made before upgrading.
|
||||
3. Downgrade the integration (HACS > Integrations > NestQuest > Redownload > 0.3.0) or replace `custom_components/nestquest/` manually.
|
||||
4. Restart Home Assistant.
|
||||
|
||||
Without a backup, the only path is staying on 0.4.0 (recommended) or deleting the integration and its database to start fresh (Settings > Devices & Services > NestQuest > Delete, then remove `nestquest.db`).
|
||||
|
||||
**Repository operators:** instead of uninstalling, git-revert the release merge on `main` and restore the pre-upgrade database.
|
||||
|
||||
## Version 0.3.0 — 2026-09-14
|
||||
|
||||
### Scope
|
||||
@@ -126,4 +231,4 @@ v0.1.0 is the first release — there is no previous NestQuest version to downgr
|
||||
|
||||
**Data:** the integration does not yet create its own database file, so there is no application data. However, Home Assistant persists the config entry and its selected options — removing the integration deletes that configuration, and the option values (`horizon_days`, `day_rollover_time`, `panel_idle_timeout`) will be lost on removal.
|
||||
|
||||
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
|
||||
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""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
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
"""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 /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.config import ApiConfig
|
||||
from api.database import DatabaseState, make_database
|
||||
from api.routes_panel import router as panel_router
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
state = make_database(db_path)
|
||||
app.state.db = state
|
||||
try:
|
||||
await state.open_and_migrate()
|
||||
yield
|
||||
finally:
|
||||
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) read the configured
|
||||
# values from the app rather than module state.
|
||||
app.state.config = cfg
|
||||
app.include_router(panel_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"]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""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.
|
||||
|
||||
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, and a default (or empty) service
|
||||
token would either authenticate everyone or lock the panel plane out
|
||||
for good. ``ApiConfig.from_env`` therefore RAISES when either variable
|
||||
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"
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when required API configuration is missing or invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiConfig:
|
||||
"""Resolved API configuration.
|
||||
|
||||
Both fields are required (no defaults): ``from_env`` rejects an
|
||||
unset or empty value for either, and a caller building the dataclass
|
||||
directly must pass an explicit token — the token check itself fails
|
||||
closed on an empty configured token, but expecting a caller to
|
||||
discover that at request time is a wiring mistake, so the field is
|
||||
mandatory. Kept as a dataclass so later tasks (admin plane, SSE,
|
||||
settings persistence) can extend it without touching the call sites
|
||||
that read ``db_path``.
|
||||
"""
|
||||
|
||||
db_path: str
|
||||
panel_token: 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 ``NESTQUEST_DB_PATH`` is unset
|
||||
or empty/whitespace-only — there is no silent default, because a
|
||||
default in the temp directory would silently lose data at reboot
|
||||
— and likewise when ``NESTQUEST_PANEL_TOKEN`` is unset or empty
|
||||
(a default token is never invented: the panel plane is closed
|
||||
until a real one is configured).
|
||||
"""
|
||||
source = env if env is not None else os.environ
|
||||
raw_db_path = source.get(DB_PATH_ENV, "").strip()
|
||||
if not raw_db_path:
|
||||
raise ConfigError(
|
||||
f"{DB_PATH_ENV} must be set to a writable SQLite file path "
|
||||
f"(it is unset or empty — the API will not fall back to a "
|
||||
f"temp directory and silently lose data)"
|
||||
)
|
||||
raw_panel_token = source.get(PANEL_TOKEN_ENV, "").strip()
|
||||
if not raw_panel_token:
|
||||
raise ConfigError(
|
||||
f"{PANEL_TOKEN_ENV} must be set to the panel service token "
|
||||
f"(it is unset or empty — the API will not start without "
|
||||
f"one, and no default token is invented)"
|
||||
)
|
||||
return cls(db_path=raw_db_path, panel_token=raw_panel_token)
|
||||
|
||||
|
||||
__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV", "PANEL_TOKEN_ENV"]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""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 explicit settings object (``core.settings.NestQuestSettings``)
|
||||
#: the snapshot builder's ``settings`` parameter takes.
|
||||
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
|
||||
|
||||
__all__ = [
|
||||
"core",
|
||||
"core_completion",
|
||||
"core_db",
|
||||
"core_migrations",
|
||||
"core_snapshot",
|
||||
"core_settings",
|
||||
]
|
||||
@@ -0,0 +1,305 @@
|
||||
"""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
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
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_settings,
|
||||
core_snapshot,
|
||||
)
|
||||
|
||||
#: 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",
|
||||
)
|
||||
|
||||
try:
|
||||
result = await core_completion.complete_instance(
|
||||
database,
|
||||
instance_id,
|
||||
actor_source="panel",
|
||||
actor_child_id=body.actor_child_id,
|
||||
now=_local_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.
|
||||
return {"status": str(result)}
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -2,22 +2,38 @@
|
||||
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_ADMIN_USER_IDS, DOMAIN, LOGGER
|
||||
from .db import NestQuestDatabase
|
||||
from .const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
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 .store import async_get_db_path
|
||||
from .admin_allowlist import seed_setup_admin
|
||||
from .sweep import run_missed_sweep
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
@@ -155,7 +171,7 @@ async def _async_open_database(
|
||||
"""
|
||||
try:
|
||||
await _preflight_existing_file(hass, db_path)
|
||||
database = await NestQuestDatabase(hass).open(db_path)
|
||||
database = await make_database(hass).open(db_path)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except sqlite3.DatabaseError as err:
|
||||
@@ -221,9 +237,11 @@ class NestQuestRuntimeData:
|
||||
"""Runtime data stored on a NestQuest config entry."""
|
||||
|
||||
entry_id: str
|
||||
options: dict[str, Any]
|
||||
settings: NestQuestSettings
|
||||
database: NestQuestDatabase
|
||||
remove_update_listener: Callable[[], Any]
|
||||
remove_time_change_listener: Callable[[], Any]
|
||||
coordinator: Any = None
|
||||
|
||||
|
||||
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||
@@ -248,13 +266,126 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||
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 connected database, or None.
|
||||
|
||||
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():
|
||||
database = getattr(runtime_data, "database", None)
|
||||
if database is not None and database.connected:
|
||||
return runtime_data
|
||||
return None
|
||||
|
||||
|
||||
def _make_regenerate_handler(hass: HomeAssistant):
|
||||
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
|
||||
|
||||
async def _regenerate(_call: Any) -> None:
|
||||
runtime_data = _find_live_runtime_data(hass)
|
||||
if runtime_data is None:
|
||||
_LOGGER.warning(
|
||||
"NestQuest regenerate requested but no config entry has a "
|
||||
"live database; ignoring"
|
||||
)
|
||||
return
|
||||
await _run_horizon_materialization(
|
||||
hass, runtime_data.database, runtime_data.settings
|
||||
)
|
||||
|
||||
return _regenerate
|
||||
|
||||
|
||||
def _register_services(hass: HomeAssistant) -> None:
|
||||
"""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,
|
||||
regenerate_handler=_make_regenerate_handler(hass),
|
||||
)
|
||||
|
||||
|
||||
def _deregister_services(hass: HomeAssistant) -> None:
|
||||
"""Remove domain-global NestQuest services when the last entry unloads."""
|
||||
async_deregister_services(hass)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
||||
await async_register_frontend(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up NestQuest from a config entry."""
|
||||
await async_register_frontend(hass)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
existing = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if existing is not None:
|
||||
@@ -305,6 +436,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
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
|
||||
try:
|
||||
# Seed the admin allowlist on first setup so the owner is never
|
||||
# locked out: an empty database takes the persisted admin copy
|
||||
@@ -329,17 +463,95 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
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)
|
||||
# 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,
|
||||
database=database,
|
||||
settings=settings,
|
||||
update_interval_seconds=entry.options.get(
|
||||
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
|
||||
),
|
||||
)
|
||||
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,
|
||||
)
|
||||
# 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
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
except BaseException:
|
||||
# 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:
|
||||
result = remove_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except BaseException:
|
||||
pass
|
||||
# A coordinator created before the failure holds entity
|
||||
# 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()
|
||||
except BaseException:
|
||||
pass
|
||||
# A platform-forward failure reached this unwind AFTER
|
||||
# runtime_data was assigned: clear it so no later unload path
|
||||
# closes the already-closed database through the stale record.
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
await database.close()
|
||||
raise
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
options=dict(entry.options),
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
)
|
||||
entry.runtime_data = runtime_data
|
||||
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
|
||||
|
||||
@@ -348,28 +560,71 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
runtime_data = domain_data.pop(entry.entry_id, None)
|
||||
if not domain_data:
|
||||
is_last = not domain_data
|
||||
if is_last:
|
||||
hass.data.pop(DOMAIN, None)
|
||||
if runtime_data is None:
|
||||
runtime_data = getattr(entry, "runtime_data", None)
|
||||
# Platforms unload FIRST (mirroring HA conventions): entity
|
||||
# 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 database. Restore
|
||||
# the runtime record popped above and report failure so HA
|
||||
# retries the unload later instead of leaving live entities
|
||||
# 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:
|
||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||
unload_error: BaseException | None = 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:
|
||||
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 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()
|
||||
if unload_error is not None:
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
raise unload_error
|
||||
# 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.
|
||||
if is_last:
|
||||
_deregister_services(hass)
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
if unload_error is not None:
|
||||
raise unload_error
|
||||
return True
|
||||
|
||||
@@ -1,153 +1,11 @@
|
||||
"""Business layer for the admin allowlist (Feature 03).
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
|
||||
|
||||
Sits between the options flow / Feature 09 service gate and the typed
|
||||
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
|
||||
Assistant user IDs — never usernames or display names (feature
|
||||
guardrail) — and an empty allowlist must fail closed: every function
|
||||
here refuses to write an empty allowlist, so the owner can never be
|
||||
accidentally locked out of admin operations by a bad save.
|
||||
|
||||
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
|
||||
in one write are rejected up front — they would silently map to one
|
||||
row and hide a caller bug. Writes replace the allowlist atomically:
|
||||
the diff (additions and removals) is computed and applied inside one
|
||||
transaction under the connection-scoped lock shared with the DAOs, so
|
||||
a concurrent reader never observes a half-applied sync.
|
||||
|
||||
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
|
||||
when it is EMPTY, so re-running setup never resurrects a deliberately
|
||||
narrowed list. The setup user (the HA user who completed the config
|
||||
flow, via the entry context) is added automatically so the owner is
|
||||
never locked out on first setup; a stored entry-data copy of the list
|
||||
re-seeds a lost database.
|
||||
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
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from .dao_children import AdminUsersDao, _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""UTC second-precision stamp, matching the registry's policy."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_user_id(value: object) -> str:
|
||||
"""Return ``value`` trimmed; raise unless a non-empty string."""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"admin user id must be a string, got {value!r}"
|
||||
)
|
||||
trimmed = value.strip()
|
||||
if not trimmed:
|
||||
raise ValueError("admin user id must not be empty")
|
||||
return trimmed
|
||||
|
||||
|
||||
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
|
||||
"""Return the allowlisted HA user IDs in storage order."""
|
||||
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
|
||||
|
||||
|
||||
async def set_admin_ids(
|
||||
database: NestQuestDatabase, user_ids: list[str]
|
||||
) -> None:
|
||||
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
|
||||
|
||||
Refuses an empty list — an empty allowlist must fail closed, and
|
||||
the only way to reach one through this API would be removing the
|
||||
last admin, so that removal is refused here with a clear error.
|
||||
Refuses non-string, empty, or duplicate ids up front. The diff
|
||||
against the current allowlist (additions and removals) applies
|
||||
inside one transaction under the connection lock.
|
||||
"""
|
||||
if not isinstance(user_ids, list):
|
||||
raise ValueError("user_ids must be a list of HA user id strings")
|
||||
normalized: list[str] = []
|
||||
for value in user_ids:
|
||||
normalized.append(_validate_user_id(value))
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ValueError("user_ids must not contain duplicates")
|
||||
if not normalized:
|
||||
raise ValueError(
|
||||
"refusing to empty the admin allowlist: at least one admin "
|
||||
"must remain (an empty allowlist fails closed)"
|
||||
)
|
||||
dao = AdminUsersDao(database)
|
||||
async with _connection_lock(database):
|
||||
async with database.transaction():
|
||||
current = {row.ha_user_id for row in await dao.list()}
|
||||
target = set(normalized)
|
||||
for user_id in sorted(current - target):
|
||||
await dao.remove(user_id)
|
||||
for user_id in normalized:
|
||||
await dao.add(user_id, _now_stamp())
|
||||
|
||||
|
||||
async def is_admin(
|
||||
database: NestQuestDatabase, user_id: object
|
||||
) -> bool:
|
||||
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
|
||||
|
||||
True only for an allowlisted user id. Everything else is False:
|
||||
a non-allowlisted id, an unknown id, a None or non-string value
|
||||
(a service call with no user context), and an empty allowlist
|
||||
(nobody is admin rather than everybody). The Feature 09 gate
|
||||
wraps every admin-only service with this verdict; it never raises
|
||||
for malformed input — malformed means not admin.
|
||||
"""
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
return False
|
||||
return await AdminUsersDao(database).exists(user_id)
|
||||
|
||||
|
||||
async def seed_setup_admin(
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
stored_admin_ids: list[str] | None = None,
|
||||
context_user_id: str | None = None,
|
||||
owner_ids: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Seed an empty allowlist so the owner is never locked out.
|
||||
|
||||
Only acts when the allowlist is EMPTY. Preference order: the
|
||||
stored persisted copy of the list (the options flow's current
|
||||
selection, then the config flow's original — disaster recovery for
|
||||
a lost database), then the HA user who completed the config flow
|
||||
(``context_user_id``), then — when no caller identity survived at
|
||||
all — every Home Assistant OWNER account (``owner_ids``), so a
|
||||
first setup whose flow context lost the user id still seeds a
|
||||
working admin. Returns the allowlist after seeding. Non-empty
|
||||
allowlists are returned untouched — re-running setup must never
|
||||
resurrect a deliberately narrowed list.
|
||||
"""
|
||||
dao = AdminUsersDao(database)
|
||||
async with _connection_lock(database):
|
||||
current = [row.ha_user_id for row in await dao.list()]
|
||||
if current:
|
||||
return current
|
||||
candidates: list[str] = []
|
||||
if stored_admin_ids is not None:
|
||||
if not isinstance(stored_admin_ids, list):
|
||||
raise ValueError(
|
||||
"stored_admin_ids must be a list of HA user id strings"
|
||||
)
|
||||
candidates = [_validate_user_id(value) for value in stored_admin_ids]
|
||||
elif context_user_id is not None:
|
||||
candidates = [_validate_user_id(context_user_id)]
|
||||
elif owner_ids:
|
||||
if not isinstance(owner_ids, list):
|
||||
raise ValueError(
|
||||
"owner_ids must be a list of HA user id strings"
|
||||
)
|
||||
candidates = [_validate_user_id(value) for value in owner_ids]
|
||||
for user_id in candidates:
|
||||
await dao.add(user_id, _now_stamp())
|
||||
return [row.ha_user_id for row in await dao.list()]
|
||||
from .core.admin_allowlist import * # noqa: F401,F403
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Per-child binary sensors (Feature 10, task 4).
|
||||
|
||||
Two binary sensors per active child, both reading only the coordinator
|
||||
snapshot (the same ChildDaySnapshot the day sensors read):
|
||||
|
||||
- ``binary_sensor.nestquest_<child>_all_done`` — ON exactly when the
|
||||
child owes quests today (due today > 0) AND owes none of them any
|
||||
more (remaining today == 0). A zero-quest day is OFF: the
|
||||
celebration automation must never fire on an empty day (the
|
||||
day-complete event carries the same rule, Feature 10 task 7).
|
||||
- ``binary_sensor.nestquest_<child>_present_today`` — ON when the
|
||||
presence engine resolves the child present on the HA-local date
|
||||
(schedules and overrides, D-004 anchor-date arithmetic).
|
||||
|
||||
Naming and registry identity follow the day sensors' contract: names
|
||||
slug to the documented entity_id shape; unique_ids are built from the
|
||||
child's database id, never the display name, so a rename never
|
||||
orphans an entity's history.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
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 .const import DOMAIN
|
||||
from .coordinator import ChildDaySnapshot, NestQuestCoordinator
|
||||
|
||||
|
||||
class _NestQuestChildBinarySensor(CoordinatorEntity, BinarySensorEntity):
|
||||
"""Base for the per-child binary sensors.
|
||||
|
||||
Registry identity is built from the child's database id and never
|
||||
changes; the displayed name and device label re-derive from the
|
||||
child's CURRENT snapshot on every read, so a rename propagates on
|
||||
the next refresh without orphaning history. A child absent from
|
||||
the latest snapshot (a deactivation; removal is a later task's
|
||||
scope) yields ``None`` states rather than stale values.
|
||||
"""
|
||||
|
||||
_name_suffix: str = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._child_id = child.child_id
|
||||
self._fallback_name = child.child_name
|
||||
|
||||
@property
|
||||
def _child(self) -> ChildDaySnapshot | None:
|
||||
"""Return the child's current day snapshot, or None."""
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
for child in data.children:
|
||||
if child.child_id == self._child_id:
|
||||
return child
|
||||
return None
|
||||
|
||||
def _displayed_child_name(self) -> str:
|
||||
child = self._child
|
||||
return child.child_name if child is not None else self._fallback_name
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
return f"NestQuest {self._displayed_child_name()} {self._name_suffix}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"child_{self._child_id}")},
|
||||
name=f"NestQuest {self._displayed_child_name()}",
|
||||
manufacturer="NestQuest",
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return {
|
||||
"child_id": child.child_id,
|
||||
"child_name": child.child_name,
|
||||
}
|
||||
|
||||
|
||||
class NestQuestAllDoneSensor(_NestQuestChildBinarySensor):
|
||||
"""ON when the child had quests today and owes none of them."""
|
||||
|
||||
_name_suffix = "all done"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_all_done"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
# Zero-quest days are OFF, not ON: an empty day must never fire
|
||||
# the celebration automation.
|
||||
return child.due_today > 0 and child.remaining_today == 0
|
||||
|
||||
|
||||
class NestQuestPresentTodaySensor(_NestQuestChildBinarySensor):
|
||||
"""ON when the child is at this house today (presence engine)."""
|
||||
|
||||
_name_suffix = "present today"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = f"{DOMAIN}_child_{child.child_id}_present_today"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return child.present
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Base attributes plus, 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)."""
|
||||
base = super().extra_state_attributes
|
||||
if base is None:
|
||||
return None
|
||||
child = self._child
|
||||
return {**base, "next_present": child.next_present if child else None}
|
||||
|
||||
|
||||
def _child_binary_sensors(
|
||||
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
|
||||
) -> tuple[_NestQuestChildBinarySensor, ...]:
|
||||
"""Build the two documented binary sensors for one active child."""
|
||||
return (
|
||||
NestQuestAllDoneSensor(coordinator, child),
|
||||
NestQuestPresentTodaySensor(coordinator, child),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[..., None],
|
||||
) -> None:
|
||||
"""Create the two binary sensors per active child.
|
||||
|
||||
Entities are created for the snapshot's children at platform
|
||||
setup; a coordinator listener adds sensors for children that first
|
||||
appear on a later refresh (the day sensors' same pattern, so a new
|
||||
child's whole entity set lands together). Deactivated children
|
||||
are NOT removed here — removal is a later task's scope.
|
||||
"""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
known_child_ids: set[int] = set()
|
||||
|
||||
def _add_new_children(children: Iterable[ChildDaySnapshot]) -> None:
|
||||
new_children = [
|
||||
child
|
||||
for child in children
|
||||
if child.child_id not in known_child_ids
|
||||
]
|
||||
if not new_children:
|
||||
return
|
||||
known_child_ids.update(child.child_id for child in new_children)
|
||||
async_add_entities(
|
||||
[
|
||||
sensor
|
||||
for child in new_children
|
||||
for sensor in _child_binary_sensors(coordinator, child)
|
||||
]
|
||||
)
|
||||
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
|
||||
def _handle_coordinator_update() -> None:
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
|
||||
coordinator.async_add_listener(_handle_coordinator_update)
|
||||
@@ -0,0 +1,90 @@
|
||||
blueprint:
|
||||
name: NestQuest afternoon reminder
|
||||
description: >-
|
||||
Sends an afternoon nudge listing ONLY the children who still have
|
||||
open quests today. Children who are present and all clear are
|
||||
omitted (the nudge is for stragglers, not a roll call), children
|
||||
who are not present today are omitted entirely, and the household
|
||||
rollup sensors are excluded by the same present guard. When every
|
||||
present child is already done the automation sends NOTHING at all:
|
||||
a template condition stops the run before the notification. The
|
||||
notify service is chosen by the household when importing this
|
||||
blueprint — NestQuest never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
afternoon_time:
|
||||
name: Afternoon time
|
||||
description: Local time of day the reminder is sent.
|
||||
default: "15:00"
|
||||
selector:
|
||||
time: {}
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the reminder,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
send_reminder:
|
||||
name: Send the reminder
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
mode: single
|
||||
|
||||
variables:
|
||||
send_reminder: !input send_reminder
|
||||
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: !input afternoon_time
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ send_reminder | bool }}"
|
||||
# Send nothing when every present child is already done: the guard is
|
||||
# false unless at least one present child has remaining quests today.
|
||||
- condition: template
|
||||
value_template: |
|
||||
{%- set ns = namespace(any_open=false) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
|
||||
{%- set ns.any_open = true -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- ns.any_open -}}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest afternoon reminder
|
||||
message: |
|
||||
{%- set ns = namespace(lines=[]) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
|
||||
{%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%}
|
||||
{%- set ns.lines = ns.lines + [line] -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- ns.lines | join('\n') -}}
|
||||
@@ -0,0 +1,109 @@
|
||||
blueprint:
|
||||
name: NestQuest day complete celebration
|
||||
description: >-
|
||||
Celebrates the moment a child clears every quest for the day: the
|
||||
automation is driven by the nestquest_child_day_complete event,
|
||||
which NestQuest only fires when quests were owed and all of them
|
||||
are complete — a zero-quest day can never trigger this
|
||||
celebration. Once-per-child-per-day dedupe: point
|
||||
last_celebrated_helper at ONE input_text helper (create it under
|
||||
Settings, Devices and Services, Helpers — plain text). The
|
||||
automation records each celebrated child as
|
||||
"<child_id>:<today>" inside that helper, and a child who
|
||||
un-completes a quest and completes it again the same day is
|
||||
celebrated once, not twice — while a SECOND child clearing their
|
||||
day the same date is still celebrated in full. The helper is
|
||||
pruned to today's entries on every celebration, so it never
|
||||
grows past one entry per child per day. A helper that does not
|
||||
exist yet, or has never been set, lets the first celebration
|
||||
through. The notification message may use the event payload's
|
||||
child_name, quests_due and quests_completed. The notify service
|
||||
is chosen by the household when importing this blueprint —
|
||||
NestQuest never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the celebration,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
celebration_message:
|
||||
name: Celebration message
|
||||
description: >-
|
||||
The message sent when a child clears every quest today. May
|
||||
use the event payload: trigger.event.data.child_name,
|
||||
trigger.event.data.quests_due and trigger.event.data.quests_completed.
|
||||
default: "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
|
||||
selector:
|
||||
text: {}
|
||||
celebrate:
|
||||
name: Send the celebration
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
last_celebrated_helper:
|
||||
name: Last-celebrated helper
|
||||
description: >-
|
||||
A single input_text helper remembering which children were
|
||||
already celebrated today, as "<child_id>:<date>" entries
|
||||
separated by commas. The automation skips a child whose entry
|
||||
for today is already stored, and rewrites the helper with only
|
||||
today's entries plus the new one after each celebration.
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
- domain: input_text
|
||||
|
||||
mode: queued
|
||||
|
||||
variables:
|
||||
celebrate: !input celebrate
|
||||
last_celebrated_helper: !input last_celebrated_helper
|
||||
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: nestquest_child_day_complete
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ celebrate | bool }}"
|
||||
# Once-per-child-per-day dedupe: celebrate only when THIS child has
|
||||
# no equal entry for today in the helper. Entries compare as whole
|
||||
# comma-separated tokens — a substring check would wrongly suppress
|
||||
# child 1 after child 11 was celebrated. An unset or missing helper
|
||||
# reads as '' and celebrates.
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d'))
|
||||
not in ((states(last_celebrated_helper) or '')
|
||||
.split(',')
|
||||
| select('ne', '')
|
||||
| list) }}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest day complete
|
||||
message: !input celebration_message
|
||||
# Record this child's celebration, keeping only today's entries so the
|
||||
# helper never outgrows its text limit and stale entries cannot block
|
||||
# next week's celebrations.
|
||||
- action: input_text.set_value
|
||||
target:
|
||||
entity_id: !input last_celebrated_helper
|
||||
data:
|
||||
value: >-
|
||||
{%- set today = now().strftime('%Y-%m-%d') -%}
|
||||
{%- set ns = namespace(entries=[]) -%}
|
||||
{%- for entry in (states(last_celebrated_helper) or '').split(',') -%}
|
||||
{%- if entry and entry.endswith(':' ~ today) -%}
|
||||
{%- set ns.entries = ns.entries + [entry] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{ (ns.entries + [trigger.event.data.child_id ~ ':' ~ today]) | join(',') }}
|
||||
@@ -0,0 +1,121 @@
|
||||
blueprint:
|
||||
name: NestQuest end-of-day report
|
||||
description: >-
|
||||
Sends the parent an end-of-day report on how the day went. It runs
|
||||
deliberately BEFORE NestQuest's midnight missed-quest sweep, so it
|
||||
reads the same day's data (what is still open at report time, and
|
||||
how much of the day was completed) rather than tomorrow-morning's
|
||||
hindsight. Every PRESENT child gets a completion line, so the
|
||||
parent always receives the day's summary; children who still owe
|
||||
quests get their open count called out on that line, and children
|
||||
who are not present today are omitted entirely. A child whose
|
||||
remaining-count sensor is unavailable is reported with the
|
||||
completion line only. By default the report is skipped when every
|
||||
present child is already done — turn on send-even-when-clear to
|
||||
always get the day's completion summary. The notify service is
|
||||
chosen by the household when importing this blueprint — NestQuest
|
||||
never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
report_time:
|
||||
name: Report time
|
||||
description: >-
|
||||
Local time of day the report is sent — deliberately before the
|
||||
midnight missed-quest sweep, so the report reads the same
|
||||
day's data.
|
||||
default: "20:00"
|
||||
selector:
|
||||
time: {}
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the report,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
send_report:
|
||||
name: Send the report
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
send_even_when_clear:
|
||||
name: Send even when clear
|
||||
description: >-
|
||||
Send the report even when every present child has already
|
||||
finished their quests today. When off (the default), an
|
||||
all-clear day sends nothing.
|
||||
default: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
mode: single
|
||||
|
||||
variables:
|
||||
send_report: !input send_report
|
||||
send_even_when_clear: !input send_even_when_clear
|
||||
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: !input report_time
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ send_report | bool }}"
|
||||
# Skip an all-clear day unless the household opted into
|
||||
# send-even-when-clear: the guard is false only when every present
|
||||
# child has a usable remaining count of zero. A MISSING or
|
||||
# non-numeric remaining sensor reads as -1 and counts as open —
|
||||
# a degraded state must reach the parent, not look like all-clear.
|
||||
- condition: template
|
||||
value_template: |
|
||||
{%- set ns = namespace(any_open=false) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if not remaining_values or (remaining_values | first | int(-1)) != 0 -%}
|
||||
{%- set ns.any_open = true -%}
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- send_even_when_clear | bool or ns.any_open -}}
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest end-of-day report
|
||||
message: |
|
||||
{%- set ns = namespace(lines=[]) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set due_count = due.state | int -%}
|
||||
{%- set completed_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_completed_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- set completed_count = (completed_values | first | int) if completed_values else 0 -%}
|
||||
{%- set remaining_count = (remaining_values | first | int) if remaining_values else none -%}
|
||||
{%- if remaining_count is not none and remaining_count > 0 -%}
|
||||
{%- set line = due.attributes.child_name ~ ': ' ~ remaining_count ~ ' still open (' ~ completed_count ~ '/' ~ due_count ~ ' done).' -%}
|
||||
{%- else -%}
|
||||
{%- set line = due.attributes.child_name ~ ': ' ~ completed_count ~ '/' ~ due_count ~ ' done.' -%}
|
||||
{%- endif -%}
|
||||
{%- set ns.lines = ns.lines + [line] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- ns.lines | join('\n') -}}
|
||||
@@ -0,0 +1,78 @@
|
||||
blueprint:
|
||||
name: NestQuest morning summary
|
||||
description: >-
|
||||
Sends a morning notification listing what each child still owes
|
||||
today. Only children whose NestQuest presence sensor is on are
|
||||
included; children who are not present today are omitted entirely.
|
||||
The notify service is chosen by the household when importing this
|
||||
blueprint — NestQuest never hard-codes a personal notify target.
|
||||
domain: automation
|
||||
input:
|
||||
morning_time:
|
||||
name: Morning time
|
||||
description: Local time of day the summary is sent.
|
||||
default: "08:00"
|
||||
selector:
|
||||
time: {}
|
||||
notify_target:
|
||||
name: Notify service
|
||||
description: >-
|
||||
The Home Assistant notify service to call for the summary,
|
||||
for example notify.mobile_app_your_phone — a placeholder;
|
||||
pick the notify service for your own device.
|
||||
selector:
|
||||
text: {}
|
||||
send_summary:
|
||||
name: Send the summary
|
||||
description: >-
|
||||
Turn off to keep the automation installed but skip the
|
||||
notification entirely.
|
||||
default: true
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
mode: single
|
||||
|
||||
variables:
|
||||
send_summary: !input send_summary
|
||||
|
||||
triggers:
|
||||
- trigger: time
|
||||
at: !input morning_time
|
||||
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ send_summary | bool }}"
|
||||
|
||||
actions:
|
||||
- action: !input notify_target
|
||||
data:
|
||||
title: NestQuest morning summary
|
||||
message: |
|
||||
{%- set ns = namespace(lines=[]) -%}
|
||||
{%- for due in states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
|
||||
{%- if due.attributes.get('present') -%}
|
||||
{%- set remaining_values = states.sensor
|
||||
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
|
||||
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
|
||||
| map(attribute='state')
|
||||
| list -%}
|
||||
{%- if remaining_values -%}
|
||||
{%- set remaining = remaining_values | first | int -%}
|
||||
{%- if remaining > 0 -%}
|
||||
{%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%}
|
||||
{%- else -%}
|
||||
{%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%}
|
||||
{%- endif -%}
|
||||
{%- else -%}
|
||||
{%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%}
|
||||
{%- endif -%}
|
||||
{%- set ns.lines = ns.lines + [line] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- if ns.lines -%}
|
||||
{{- ns.lines | join('\n') -}}
|
||||
{%- else -%}
|
||||
No children present today.
|
||||
{%- endif -%}
|
||||
@@ -1,280 +1,11 @@
|
||||
"""Business layer for the children registry (Feature 03).
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
|
||||
|
||||
Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_children.ChildrenDao`: callers get validation, name
|
||||
policies and the duplicate-name warning here, and never talk to the
|
||||
DAO or SQL directly. Children are never hard-deleted — deactivation
|
||||
is a separate explicit operation (:func:`set_child_active`), so
|
||||
completion history keeps its references (feature guardrail).
|
||||
|
||||
Name policy: the display name is required and trimmed; an empty or
|
||||
whitespace-only name is rejected with ValueError. A duplicate display
|
||||
name is ALLOWED (two children may legitimately share a nickname) but
|
||||
logged as a warning so an accidental double-add is visible in the HA
|
||||
log. The duplicate check is case-insensitive and whitespace-normalised
|
||||
on both sides, because "ada" and "Ada " are the same name to a parent.
|
||||
|
||||
Colour and avatar are optional free-form strings, trimmed when present;
|
||||
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
|
||||
optional field back to NULL is deliberately rejected: the DAO's update
|
||||
writes only provided fields (it cannot express NULL), so the business
|
||||
layer refuses an explicit ``None`` instead of silently ignoring it.
|
||||
Timestamps follow the module timestamp policy: the business layer
|
||||
stamps UTC ISO-8601 at second precision so every row it creates is
|
||||
uniform.
|
||||
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
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
||||
#: in :func:`edit_child`.
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_text(
|
||||
value: object, field: str, *, required: bool = False
|
||||
) -> str | None:
|
||||
"""Validate a free-text field and return it trimmed.
|
||||
|
||||
``None`` raises when ``required`` and otherwise returns None. A
|
||||
non-string value is rejected; an empty result is rejected for
|
||||
required fields (whitespace-only names are empty to a parent).
|
||||
"""
|
||||
if value is None:
|
||||
if required:
|
||||
raise ValueError(f"{field} is required")
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||
trimmed = value.strip()
|
||||
if required and not trimmed:
|
||||
raise ValueError(f"{field} is required")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _validate_sort_order(value: object) -> int:
|
||||
"""Reject non-int sort orders (bools included) before the DAO."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"sort_order must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools included) before any lookup.
|
||||
|
||||
SQLite binds Python bools as integers, so ``True`` would silently
|
||||
address child 1 and a float would round — malformed service input
|
||||
must never mutate the wrong profile.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def _warn_on_duplicate_name(
|
||||
database: NestQuestDatabase,
|
||||
display_name: str,
|
||||
*,
|
||||
exclude_child_id: int | None = None,
|
||||
) -> None:
|
||||
"""Log a warning when ``display_name`` duplicates an existing child.
|
||||
|
||||
Case-insensitive and whitespace-normalised on both sides. The
|
||||
duplicate is still allowed — this only makes it visible; the log
|
||||
line names both rows so an accidental double-add can be found.
|
||||
``exclude_child_id`` removes the child being edited from the scan,
|
||||
so an edit only warns when the result collides with a DIFFERENT
|
||||
child's name — including case-only edits, where the self-comparison
|
||||
would otherwise hide a real duplicate.
|
||||
"""
|
||||
lowered = display_name.casefold()
|
||||
for child in await ChildrenDao(database).list_all():
|
||||
if exclude_child_id is not None and child.id == exclude_child_id:
|
||||
continue
|
||||
if child.display_name.strip().casefold() == lowered:
|
||||
LOGGER.warning(
|
||||
"Child display name %r duplicates existing child %d "
|
||||
"(%r); allowed, but check this was intentional",
|
||||
display_name,
|
||||
child.id,
|
||||
child.display_name,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def create_child(
|
||||
database: NestQuestDatabase,
|
||||
display_name: str,
|
||||
*,
|
||||
colour: str | None = None,
|
||||
avatar_ref: str | None = None,
|
||||
sort_order: int = 0,
|
||||
) -> ChildRecord:
|
||||
"""Create a child profile and return the stored record.
|
||||
|
||||
The display name is required, trimmed, and rejected when empty.
|
||||
A duplicate display name (case-insensitive) is allowed but logged
|
||||
as a warning. The scan and the INSERT run inside the
|
||||
connection-scoped lock shared with the DAOs, so a concurrent
|
||||
create of the same normalised name queues behind the first insert
|
||||
and sees it — the duplicate still warns; it can never slip through
|
||||
unlogged.
|
||||
"""
|
||||
name = _validate_text(display_name, "display_name", required=True)
|
||||
assert name is not None
|
||||
colour_value = _validate_text(colour, "colour")
|
||||
avatar_value = _validate_text(avatar_ref, "avatar_ref")
|
||||
order = _validate_sort_order(sort_order)
|
||||
async with _connection_lock(database):
|
||||
await _warn_on_duplicate_name(database, name)
|
||||
created = await ChildrenDao(database).create(
|
||||
name,
|
||||
_now_stamp(),
|
||||
colour=colour_value,
|
||||
avatar_ref=avatar_value,
|
||||
sort_order=order,
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
async def edit_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
*,
|
||||
display_name: str | object = _UNSET,
|
||||
colour: str | None | object = _UNSET,
|
||||
avatar_ref: str | None | object = _UNSET,
|
||||
sort_order: int | object = _UNSET,
|
||||
) -> ChildRecord:
|
||||
"""Edit a child profile and return the updated record.
|
||||
|
||||
Arguments default to the module sentinel ``_UNSET`` meaning "leave
|
||||
this field alone". An explicitly passed ``None`` for an optional
|
||||
field raises: the DAO's update writes only provided fields and
|
||||
cannot express NULL, so a silent no-op would mask the caller's
|
||||
intent (see the module docstring). A provided display name is
|
||||
validated like create's; the duplicate scan excludes only the child
|
||||
being edited, so a case-only edit colliding with another child's
|
||||
normalised name still warns. Raises ValueError when the child does
|
||||
not exist.
|
||||
|
||||
Atomicity: the existence check, the duplicate scan, the UPDATE and
|
||||
the readback all run inside the connection-scoped lock shared with
|
||||
the DAOs, so another edit cannot slip between this call's UPDATE
|
||||
and its readback — the returned record is exactly the one this
|
||||
edit produced, not a later writer's.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
async with _connection_lock(database):
|
||||
dao = ChildrenDao(database)
|
||||
existing = await dao.get(child_id)
|
||||
if existing is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
updates: dict[str, object] = {}
|
||||
if display_name is not _UNSET:
|
||||
name = _validate_text(
|
||||
display_name, "display_name", required=True
|
||||
)
|
||||
assert name is not None
|
||||
await _warn_on_duplicate_name(
|
||||
database, name, exclude_child_id=child_id
|
||||
)
|
||||
updates["display_name"] = name
|
||||
if colour is not _UNSET:
|
||||
value = _validate_text(colour, "colour")
|
||||
if value is None:
|
||||
raise ValueError(
|
||||
"clearing colour is not supported; pass a replacement "
|
||||
"value or omit the field"
|
||||
)
|
||||
updates["colour"] = value
|
||||
if avatar_ref is not _UNSET:
|
||||
value = _validate_text(avatar_ref, "avatar_ref")
|
||||
if value is None:
|
||||
raise ValueError(
|
||||
"clearing avatar_ref is not supported; pass a "
|
||||
"replacement value or omit the field"
|
||||
)
|
||||
updates["avatar_ref"] = value
|
||||
if sort_order is not _UNSET:
|
||||
updates["sort_order"] = _validate_sort_order(sort_order)
|
||||
if updates:
|
||||
await dao.update(child_id, **updates)
|
||||
edited = await dao.get(child_id)
|
||||
assert edited is not None
|
||||
return edited
|
||||
|
||||
|
||||
async def set_child_active(
|
||||
database: NestQuestDatabase, child_id: int, is_active: bool
|
||||
) -> ChildRecord:
|
||||
"""Deactivate or reactivate a child; returns the updated record.
|
||||
|
||||
Deactivation hides the child from the panel without deleting their
|
||||
history (feature guardrail); this is the only removal path.
|
||||
``is_active`` must be a real bool: the DAO's ``int()`` would
|
||||
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||
state the caller never asked for. Raises ValueError when the
|
||||
child does not exist.
|
||||
|
||||
Atomicity: existence check, UPDATE and readback run inside the
|
||||
connection-scoped lock, so a concurrent opposite transition cannot
|
||||
make this call report the other caller's value.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
async with _connection_lock(database):
|
||||
dao = ChildrenDao(database)
|
||||
if await dao.get(child_id) is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
if not isinstance(is_active, bool):
|
||||
raise ValueError(
|
||||
f"is_active must be a real bool, got {is_active!r}"
|
||||
)
|
||||
await dao.set_active(child_id, is_active)
|
||||
updated = await dao.get(child_id)
|
||||
assert updated is not None
|
||||
return updated
|
||||
|
||||
|
||||
async def reorder_children(
|
||||
database: NestQuestDatabase, ordered_ids: list[int]
|
||||
) -> None:
|
||||
"""Rewrite the children's panel display order in one transaction.
|
||||
|
||||
``ordered_ids`` must be a complete permutation of the children
|
||||
table — every child id exactly once. A partial list (missing an
|
||||
existing child), an unknown id, or a duplicate is rejected with
|
||||
ValueError before any write, so the current order survives a
|
||||
rejected call. The DAO enforces this inside its own transaction
|
||||
(check and write are atomic under the connection lock); this
|
||||
wrapper additionally rejects non-integer ids up front, since
|
||||
``bool`` and floats would silently compare against stored integer
|
||||
ids at the SQL layer.
|
||||
"""
|
||||
for child_id in ordered_ids:
|
||||
_validate_child_id(child_id)
|
||||
await ChildrenDao(database).reorder(ordered_ids)
|
||||
|
||||
|
||||
async def list_children(
|
||||
database: NestQuestDatabase, *, active_only: bool = False
|
||||
) -> list[ChildRecord]:
|
||||
"""Return children in display order (sort_order, then id)."""
|
||||
dao = ChildrenDao(database)
|
||||
if active_only:
|
||||
return await dao.list_active()
|
||||
return await dao.list_all()
|
||||
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
|
||||
@@ -1,53 +1,10 @@
|
||||
"""Constants for the NestQuest integration."""
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.const` for the integration package.
|
||||
|
||||
The canonical constants live in :mod:`custom_components.nestquest.core.const` (the storage layer
|
||||
extracted in task d0b691d8); this module re-exports them so existing
|
||||
``from .const import ...`` references inside the integration keep
|
||||
working without touching every import line.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
DOMAIN = "nestquest"
|
||||
LOGGER_NAME = "custom_components.nestquest"
|
||||
LOGGER = logging.getLogger(LOGGER_NAME)
|
||||
|
||||
PLATFORMS: list[str] = []
|
||||
|
||||
SQLITE_DB_FILENAME = "nestquest.db"
|
||||
|
||||
# Configuration keys and default values
|
||||
CONF_HORIZON_DAYS = "horizon_days"
|
||||
DEFAULT_HORIZON_DAYS = 14
|
||||
|
||||
CONF_DAY_ROLLOVER_TIME = "day_rollover_time"
|
||||
DEFAULT_DAY_ROLLOVER_TIME = "00:00"
|
||||
|
||||
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT = 300
|
||||
|
||||
# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only
|
||||
# services. Stored as IDs, never usernames (feature guardrail). The
|
||||
# database allowlist is authoritative; the config entry carries the same
|
||||
# list as a disaster-recovery copy so a lost database can be re-seeded.
|
||||
CONF_ADMIN_USER_IDS = "admin_user_ids"
|
||||
|
||||
# Quest windows (D-008): a definition may declare several day windows;
|
||||
# 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
|
||||
# configurable later. Boundaries: a window's end equals the next
|
||||
# window's start; classification (Feature 07 materialisation) treats a
|
||||
# due time exactly on the boundary as belonging to the LATER window, so
|
||||
# 12:00 is afternoon (not morning) and 17:00 is evening (not
|
||||
# afternoon). Times after 21:00 belong to no window.
|
||||
WINDOW_MORNING = "morning"
|
||||
WINDOW_AFTERNOON = "afternoon"
|
||||
WINDOW_EVENING = "evening"
|
||||
|
||||
QUEST_WINDOWS: tuple[str, ...] = (
|
||||
WINDOW_MORNING,
|
||||
WINDOW_AFTERNOON,
|
||||
WINDOW_EVENING,
|
||||
)
|
||||
|
||||
#: Inclusive "HH:MM" bounds per window, in 24-hour local time.
|
||||
WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = {
|
||||
WINDOW_MORNING: ("00:00", "11:59"),
|
||||
WINDOW_AFTERNOON: ("12:00", "17:00"),
|
||||
WINDOW_EVENING: ("17:00", "21:00"),
|
||||
}
|
||||
from .core.const import * # noqa: F401,F403
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""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
|
||||
:func:`~.completion.derive_state`), each child's presence today, and
|
||||
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.
|
||||
|
||||
"Today" and "now" are HA-local (``hass.config.time_zone``), never the
|
||||
host clock and never UTC, matching the materialization and completion
|
||||
layers.
|
||||
|
||||
The snapshot assembly itself lives in the Home-Assistant-free
|
||||
:mod:`custom_components.nestquest.core.snapshot` module
|
||||
(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin
|
||||
HA wrapper that resolves the local timezone and reads the settings handed
|
||||
into its constructor, then delegates to that builder, passing the SAME
|
||||
HA-local ``now`` it resolved in one clock read. The API service's panel
|
||||
snapshot route shares the same builder, so both consumers render ONE
|
||||
snapshot shape.
|
||||
|
||||
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).
|
||||
|
||||
``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
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import (
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
)
|
||||
from .core.db import NestQuestDatabase
|
||||
from .core.settings import NestQuestSettings
|
||||
from .core.snapshot import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestSnapshot,
|
||||
QuestInstanceView,
|
||||
build_snapshot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ChildDaySnapshot",
|
||||
"NestQuestCoordinator",
|
||||
"NestQuestSnapshot",
|
||||
"QuestInstanceView",
|
||||
]
|
||||
|
||||
|
||||
class NestQuestCoordinator(DataUpdateCoordinator):
|
||||
"""Shared refresh cycle for every NestQuest entity.
|
||||
|
||||
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
|
||||
passes, or an older pass could publish a stale snapshot AFTER a
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
entry_id: str,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings,
|
||||
update_interval_seconds: int | None = None,
|
||||
) -> None:
|
||||
interval = update_interval_seconds
|
||||
if (
|
||||
not isinstance(interval, int)
|
||||
or isinstance(interval, bool)
|
||||
or interval < MIN_UPDATE_INTERVAL
|
||||
):
|
||||
interval = DEFAULT_UPDATE_INTERVAL
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=datetime.timedelta(seconds=interval),
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.database = database
|
||||
self.settings = settings
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
|
||||
async def async_refresh(self) -> None:
|
||||
"""One refresh at a time; a queued refresh runs AFTER the
|
||||
in-flight one publishes, so the last snapshot always reflects
|
||||
the latest mutation."""
|
||||
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:
|
||||
now = self._local_now()
|
||||
return await build_snapshot(
|
||||
self.database, self.settings, now
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""NestQuest storage layer.
|
||||
|
||||
A self-contained, Home-Assistant-free package holding the NestQuest
|
||||
SQLite storage layer: schema DDL, the versioned migration runner, the
|
||||
async connection wrapper, the path resolver, the domain constants and
|
||||
recurrence model, and the typed DAOs. Nothing in this package imports
|
||||
``homeassistant``; the integration wires it into HA at its own layer
|
||||
(constructing :class:`custom_components.nestquest.core.db.NestQuestDatabase`
|
||||
with an executor adapter backed by ``hass.async_add_executor_job``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Business layer for the admin allowlist (Feature 03).
|
||||
|
||||
Sits between the options flow / Feature 09 service gate and the typed
|
||||
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
|
||||
Assistant user IDs — never usernames or display names (feature
|
||||
guardrail) — and an empty allowlist must fail closed: every function
|
||||
here refuses to write an empty allowlist, so the owner can never be
|
||||
accidentally locked out of admin operations by a bad save.
|
||||
|
||||
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
|
||||
in one write are rejected up front — they would silently map to one
|
||||
row and hide a caller bug. Writes replace the allowlist atomically:
|
||||
the diff (additions and removals) is computed and applied inside one
|
||||
transaction under the connection-scoped lock shared with the DAOs, so
|
||||
a concurrent reader never observes a half-applied sync.
|
||||
|
||||
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
|
||||
when it is EMPTY, so re-running setup never resurrects a deliberately
|
||||
narrowed list. The setup user (the HA user who completed the config
|
||||
flow, via the entry context) is added automatically so the owner is
|
||||
never locked out on first setup; a stored entry-data copy of the list
|
||||
re-seeds a lost database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from .dao_children import AdminUsersDao, _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""UTC second-precision stamp, matching the registry's policy."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_user_id(value: object) -> str:
|
||||
"""Return ``value`` trimmed; raise unless a non-empty string."""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"admin user id must be a string, got {value!r}"
|
||||
)
|
||||
trimmed = value.strip()
|
||||
if not trimmed:
|
||||
raise ValueError("admin user id must not be empty")
|
||||
return trimmed
|
||||
|
||||
|
||||
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
|
||||
"""Return the allowlisted HA user IDs in storage order."""
|
||||
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
|
||||
|
||||
|
||||
async def set_admin_ids(
|
||||
database: NestQuestDatabase, user_ids: list[str]
|
||||
) -> None:
|
||||
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
|
||||
|
||||
Refuses an empty list — an empty allowlist must fail closed, and
|
||||
the only way to reach one through this API would be removing the
|
||||
last admin, so that removal is refused here with a clear error.
|
||||
Refuses non-string, empty, or duplicate ids up front. The diff
|
||||
against the current allowlist (additions and removals) applies
|
||||
inside one transaction under the connection lock.
|
||||
"""
|
||||
if not isinstance(user_ids, list):
|
||||
raise ValueError("user_ids must be a list of HA user id strings")
|
||||
normalized: list[str] = []
|
||||
for value in user_ids:
|
||||
normalized.append(_validate_user_id(value))
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise ValueError("user_ids must not contain duplicates")
|
||||
if not normalized:
|
||||
raise ValueError(
|
||||
"refusing to empty the admin allowlist: at least one admin "
|
||||
"must remain (an empty allowlist fails closed)"
|
||||
)
|
||||
dao = AdminUsersDao(database)
|
||||
async with _connection_lock(database):
|
||||
async with database.transaction():
|
||||
current = {row.ha_user_id for row in await dao.list()}
|
||||
target = set(normalized)
|
||||
for user_id in sorted(current - target):
|
||||
await dao.remove(user_id)
|
||||
for user_id in normalized:
|
||||
await dao.add(user_id, _now_stamp())
|
||||
|
||||
|
||||
async def is_admin(
|
||||
database: NestQuestDatabase, user_id: object
|
||||
) -> bool:
|
||||
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
|
||||
|
||||
True only for an allowlisted user id. Everything else is False:
|
||||
a non-allowlisted id, an unknown id, a None or non-string value
|
||||
(a service call with no user context), and an empty allowlist
|
||||
(nobody is admin rather than everybody). The Feature 09 gate
|
||||
wraps every admin-only service with this verdict; it never raises
|
||||
for malformed input — malformed means not admin.
|
||||
"""
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
return False
|
||||
return await AdminUsersDao(database).exists(user_id)
|
||||
|
||||
|
||||
async def seed_setup_admin(
|
||||
database: NestQuestDatabase,
|
||||
*,
|
||||
stored_admin_ids: list[str] | None = None,
|
||||
context_user_id: str | None = None,
|
||||
owner_ids: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Seed an empty allowlist so the owner is never locked out.
|
||||
|
||||
Only acts when the allowlist is EMPTY. Preference order: the
|
||||
stored persisted copy of the list (the options flow's current
|
||||
selection, then the config flow's original — disaster recovery for
|
||||
a lost database), then the HA user who completed the config flow
|
||||
(``context_user_id``), then — when no caller identity survived at
|
||||
all — every Home Assistant OWNER account (``owner_ids``), so a
|
||||
first setup whose flow context lost the user id still seeds a
|
||||
working admin. Returns the allowlist after seeding. Non-empty
|
||||
allowlists are returned untouched — re-running setup must never
|
||||
resurrect a deliberately narrowed list.
|
||||
"""
|
||||
dao = AdminUsersDao(database)
|
||||
async with _connection_lock(database):
|
||||
current = [row.ha_user_id for row in await dao.list()]
|
||||
if current:
|
||||
return current
|
||||
candidates: list[str] = []
|
||||
if stored_admin_ids is not None:
|
||||
if not isinstance(stored_admin_ids, list):
|
||||
raise ValueError(
|
||||
"stored_admin_ids must be a list of HA user id strings"
|
||||
)
|
||||
candidates = [_validate_user_id(value) for value in stored_admin_ids]
|
||||
elif context_user_id is not None:
|
||||
candidates = [_validate_user_id(context_user_id)]
|
||||
elif owner_ids:
|
||||
if not isinstance(owner_ids, list):
|
||||
raise ValueError(
|
||||
"owner_ids must be a list of HA user id strings"
|
||||
)
|
||||
candidates = [_validate_user_id(value) for value in owner_ids]
|
||||
for user_id in candidates:
|
||||
await dao.add(user_id, _now_stamp())
|
||||
return [row.ha_user_id for row in await dao.list()]
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Business layer for the children registry (Feature 03).
|
||||
|
||||
Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_children.ChildrenDao`: callers get validation, name
|
||||
policies and the duplicate-name warning here, and never talk to the
|
||||
DAO or SQL directly. Children are never hard-deleted — deactivation
|
||||
is a separate explicit operation (:func:`set_child_active`), so
|
||||
completion history keeps its references (feature guardrail).
|
||||
|
||||
Name policy: the display name is required and trimmed; an empty or
|
||||
whitespace-only name is rejected with ValueError. A duplicate display
|
||||
name is ALLOWED (two children may legitimately share a nickname) but
|
||||
logged as a warning so an accidental double-add is visible in the HA
|
||||
log. The duplicate check is case-insensitive and whitespace-normalised
|
||||
on both sides, because "ada" and "Ada " are the same name to a parent.
|
||||
|
||||
Colour and avatar are optional free-form strings, trimmed when present;
|
||||
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
|
||||
optional field back to NULL is deliberately rejected: the DAO's update
|
||||
writes only provided fields (it cannot express NULL), so the business
|
||||
layer refuses an explicit ``None`` instead of silently ignoring it.
|
||||
Timestamps follow the module timestamp policy: the business layer
|
||||
stamps UTC ISO-8601 at second precision so every row it creates is
|
||||
uniform.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
|
||||
#: in :func:`edit_child`.
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_text(
|
||||
value: object, field: str, *, required: bool = False
|
||||
) -> str | None:
|
||||
"""Validate a free-text field and return it trimmed.
|
||||
|
||||
``None`` raises when ``required`` and otherwise returns None. A
|
||||
non-string value is rejected; an empty result is rejected for
|
||||
required fields (whitespace-only names are empty to a parent).
|
||||
"""
|
||||
if value is None:
|
||||
if required:
|
||||
raise ValueError(f"{field} is required")
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||
trimmed = value.strip()
|
||||
if required and not trimmed:
|
||||
raise ValueError(f"{field} is required")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _validate_sort_order(value: object) -> int:
|
||||
"""Reject non-int sort orders (bools included) before the DAO."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"sort_order must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools included) before any lookup.
|
||||
|
||||
SQLite binds Python bools as integers, so ``True`` would silently
|
||||
address child 1 and a float would round — malformed service input
|
||||
must never mutate the wrong profile.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def _warn_on_duplicate_name(
|
||||
database: NestQuestDatabase,
|
||||
display_name: str,
|
||||
*,
|
||||
exclude_child_id: int | None = None,
|
||||
) -> None:
|
||||
"""Log a warning when ``display_name`` duplicates an existing child.
|
||||
|
||||
Case-insensitive and whitespace-normalised on both sides. The
|
||||
duplicate is still allowed — this only makes it visible; the log
|
||||
line names both rows so an accidental double-add can be found.
|
||||
``exclude_child_id`` removes the child being edited from the scan,
|
||||
so an edit only warns when the result collides with a DIFFERENT
|
||||
child's name — including case-only edits, where the self-comparison
|
||||
would otherwise hide a real duplicate.
|
||||
"""
|
||||
lowered = display_name.casefold()
|
||||
for child in await ChildrenDao(database).list_all():
|
||||
if exclude_child_id is not None and child.id == exclude_child_id:
|
||||
continue
|
||||
if child.display_name.strip().casefold() == lowered:
|
||||
LOGGER.warning(
|
||||
"Child display name %r duplicates existing child %d "
|
||||
"(%r); allowed, but check this was intentional",
|
||||
display_name,
|
||||
child.id,
|
||||
child.display_name,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def create_child(
|
||||
database: NestQuestDatabase,
|
||||
display_name: str,
|
||||
*,
|
||||
colour: str | None = None,
|
||||
avatar_ref: str | None = None,
|
||||
sort_order: int = 0,
|
||||
) -> ChildRecord:
|
||||
"""Create a child profile and return the stored record.
|
||||
|
||||
The display name is required, trimmed, and rejected when empty.
|
||||
A duplicate display name (case-insensitive) is allowed but logged
|
||||
as a warning. The scan and the INSERT run inside the
|
||||
connection-scoped lock shared with the DAOs, so a concurrent
|
||||
create of the same normalised name queues behind the first insert
|
||||
and sees it — the duplicate still warns; it can never slip through
|
||||
unlogged.
|
||||
"""
|
||||
name = _validate_text(display_name, "display_name", required=True)
|
||||
assert name is not None
|
||||
colour_value = _validate_text(colour, "colour")
|
||||
avatar_value = _validate_text(avatar_ref, "avatar_ref")
|
||||
order = _validate_sort_order(sort_order)
|
||||
async with _connection_lock(database):
|
||||
await _warn_on_duplicate_name(database, name)
|
||||
created = await ChildrenDao(database).create(
|
||||
name,
|
||||
_now_stamp(),
|
||||
colour=colour_value,
|
||||
avatar_ref=avatar_value,
|
||||
sort_order=order,
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
async def edit_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
*,
|
||||
display_name: str | object = _UNSET,
|
||||
colour: str | None | object = _UNSET,
|
||||
avatar_ref: str | None | object = _UNSET,
|
||||
sort_order: int | object = _UNSET,
|
||||
) -> ChildRecord:
|
||||
"""Edit a child profile and return the updated record.
|
||||
|
||||
Arguments default to the module sentinel ``_UNSET`` meaning "leave
|
||||
this field alone". An explicitly passed ``None`` for an optional
|
||||
field raises: the DAO's update writes only provided fields and
|
||||
cannot express NULL, so a silent no-op would mask the caller's
|
||||
intent (see the module docstring). A provided display name is
|
||||
validated like create's; the duplicate scan excludes only the child
|
||||
being edited, so a case-only edit colliding with another child's
|
||||
normalised name still warns. Raises ValueError when the child does
|
||||
not exist.
|
||||
|
||||
Atomicity: the existence check, the duplicate scan, the UPDATE and
|
||||
the readback all run inside the connection-scoped lock shared with
|
||||
the DAOs, so another edit cannot slip between this call's UPDATE
|
||||
and its readback — the returned record is exactly the one this
|
||||
edit produced, not a later writer's.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
async with _connection_lock(database):
|
||||
dao = ChildrenDao(database)
|
||||
existing = await dao.get(child_id)
|
||||
if existing is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
updates: dict[str, object] = {}
|
||||
if display_name is not _UNSET:
|
||||
name = _validate_text(
|
||||
display_name, "display_name", required=True
|
||||
)
|
||||
assert name is not None
|
||||
await _warn_on_duplicate_name(
|
||||
database, name, exclude_child_id=child_id
|
||||
)
|
||||
updates["display_name"] = name
|
||||
if colour is not _UNSET:
|
||||
value = _validate_text(colour, "colour")
|
||||
if value is None:
|
||||
raise ValueError(
|
||||
"clearing colour is not supported; pass a replacement "
|
||||
"value or omit the field"
|
||||
)
|
||||
updates["colour"] = value
|
||||
if avatar_ref is not _UNSET:
|
||||
value = _validate_text(avatar_ref, "avatar_ref")
|
||||
if value is None:
|
||||
raise ValueError(
|
||||
"clearing avatar_ref is not supported; pass a "
|
||||
"replacement value or omit the field"
|
||||
)
|
||||
updates["avatar_ref"] = value
|
||||
if sort_order is not _UNSET:
|
||||
updates["sort_order"] = _validate_sort_order(sort_order)
|
||||
if updates:
|
||||
await dao.update(child_id, **updates)
|
||||
edited = await dao.get(child_id)
|
||||
assert edited is not None
|
||||
return edited
|
||||
|
||||
|
||||
async def set_child_active(
|
||||
database: NestQuestDatabase, child_id: int, is_active: bool
|
||||
) -> ChildRecord:
|
||||
"""Deactivate or reactivate a child; returns the updated record.
|
||||
|
||||
Deactivation hides the child from the panel without deleting their
|
||||
history (feature guardrail); this is the only removal path.
|
||||
``is_active`` must be a real bool: the DAO's ``int()`` would
|
||||
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||
state the caller never asked for. Raises ValueError when the
|
||||
child does not exist.
|
||||
|
||||
Atomicity: existence check, UPDATE and readback run inside the
|
||||
connection-scoped lock, so a concurrent opposite transition cannot
|
||||
make this call report the other caller's value.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
async with _connection_lock(database):
|
||||
dao = ChildrenDao(database)
|
||||
if await dao.get(child_id) is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
if not isinstance(is_active, bool):
|
||||
raise ValueError(
|
||||
f"is_active must be a real bool, got {is_active!r}"
|
||||
)
|
||||
await dao.set_active(child_id, is_active)
|
||||
updated = await dao.get(child_id)
|
||||
assert updated is not None
|
||||
return updated
|
||||
|
||||
|
||||
async def reorder_children(
|
||||
database: NestQuestDatabase, ordered_ids: list[int]
|
||||
) -> None:
|
||||
"""Rewrite the children's panel display order in one transaction.
|
||||
|
||||
``ordered_ids`` must be a complete permutation of the children
|
||||
table — every child id exactly once. A partial list (missing an
|
||||
existing child), an unknown id, or a duplicate is rejected with
|
||||
ValueError before any write, so the current order survives a
|
||||
rejected call. The DAO enforces this inside its own transaction
|
||||
(check and write are atomic under the connection lock); this
|
||||
wrapper additionally rejects non-integer ids up front, since
|
||||
``bool`` and floats would silently compare against stored integer
|
||||
ids at the SQL layer.
|
||||
"""
|
||||
for child_id in ordered_ids:
|
||||
_validate_child_id(child_id)
|
||||
await ChildrenDao(database).reorder(ordered_ids)
|
||||
|
||||
|
||||
async def list_children(
|
||||
database: NestQuestDatabase, *, active_only: bool = False
|
||||
) -> list[ChildRecord]:
|
||||
"""Return children in display order (sort_order, then id)."""
|
||||
dao = ChildrenDao(database)
|
||||
if active_only:
|
||||
return await dao.list_active()
|
||||
return await dao.list_all()
|
||||
@@ -0,0 +1,549 @@
|
||||
"""Business layer for the completion event log (Feature 08).
|
||||
|
||||
Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_instances.CompletionEventsDao`: callers get actor-shape
|
||||
validation and the UTC occurred-at stamp here, and never talk to the
|
||||
DAO or SQL directly. This module is the only Feature-08 caller of
|
||||
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
|
||||
instance (no-op if already done) is :func:`complete_instance`;
|
||||
un-completing (no-op if already open) is :func:`uncomplete_instance`.
|
||||
Instance current state is derived from the latest event via
|
||||
:func:`instance_state` (``open`` / ``done`` / ``missed``); there is
|
||||
no status column. ``missed`` is derived for past-due open instances
|
||||
(``due_date`` before HA-local ``today``) and is never stored as an
|
||||
event_type.
|
||||
|
||||
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
|
||||
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
|
||||
requires ``actor_user_id`` and forbids ``actor_child_id``; ``'panel'``
|
||||
requires ``actor_child_id`` (the tapped profile) and forbids
|
||||
``actor_user_id``. Inverted shapes raise a field-naming ValueError
|
||||
before any write.
|
||||
|
||||
``occurred_at`` is stamped UTC ISO-8601 at second precision with an
|
||||
explicit ``+00:00`` offset, matching the DAO's one accepted shape so
|
||||
lexicographic range queries stay exact. ``was_on_time`` is required
|
||||
(True/False) for completed events; un-completions may pass None. There
|
||||
is no permission check here (that is Feature 09).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
|
||||
from .dao_children import _CONNECTION_LOCKS, _connection_lock
|
||||
from .dao_instances import (
|
||||
EVENT_COMPLETED,
|
||||
EVENT_UNCOMPLETED,
|
||||
CompletionEventRecord,
|
||||
CompletionEventsDao,
|
||||
QuestInstanceRecord,
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
def _stamp_occurred_at(now: datetime.datetime | None) -> str:
|
||||
"""Return a DAO-accepted UTC stamp for ``now``, or the current time."""
|
||||
if now is None:
|
||||
return _now_stamp()
|
||||
if not isinstance(now, datetime.datetime):
|
||||
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now must be timezone-aware")
|
||||
return now.astimezone(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
def _completion_moment(
|
||||
now: datetime.datetime | None,
|
||||
today: datetime.date | None,
|
||||
) -> tuple[datetime.date, datetime.time | None]:
|
||||
"""Return the HA-local completion date and optional wall-clock time.
|
||||
|
||||
``today`` is the HA-local calendar date when threaded (Feature 07
|
||||
style). ``now`` is a timezone-aware HA-local datetime; its wall
|
||||
clock is used as-is and is never converted to UTC. When ``today``
|
||||
is omitted the date comes from ``now``. When both are omitted the
|
||||
host calendar date is the Feature 07 fallback — never the UTC
|
||||
clock. Time-of-day is None unless ``now`` is supplied; comparing
|
||||
against ``due_time`` requires ``now``.
|
||||
"""
|
||||
if now is not None:
|
||||
if not isinstance(now, datetime.datetime):
|
||||
raise ValueError(f"now must be a datetime, got {now!r}")
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now must be timezone-aware")
|
||||
if today is not None:
|
||||
completion_date = today
|
||||
elif now is not None:
|
||||
completion_date = now.date()
|
||||
else:
|
||||
completion_date = datetime.date.today()
|
||||
if now is None:
|
||||
return completion_date, None
|
||||
return completion_date, now.time()
|
||||
|
||||
|
||||
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 _derived_state(
|
||||
instance: QuestInstanceRecord,
|
||||
latest: CompletionEventRecord | None,
|
||||
today: datetime.date,
|
||||
) -> str:
|
||||
"""Return ``open``, ``done``, or ``missed`` from event plus due date.
|
||||
|
||||
A latest completed event is ``done`` even when past due. No events
|
||||
or a latest uncompleted event is ``open``, or ``missed`` when
|
||||
``due_date`` is before ``today``. ``missed`` is never an event_type.
|
||||
"""
|
||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||
if due_date < today:
|
||||
return "missed"
|
||||
return "open"
|
||||
if latest.event_type == EVENT_COMPLETED:
|
||||
return "done"
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
|
||||
|
||||
def derive_state(
|
||||
instance: QuestInstanceRecord,
|
||||
latest: CompletionEventRecord | None,
|
||||
today: datetime.date,
|
||||
) -> str:
|
||||
"""Public state derivation for the Feature 10 coordinator.
|
||||
|
||||
Thin wrapper over :func:`_derived_state`: ``open``, ``done``, or
|
||||
``missed`` from the latest event plus the due date (``missed`` is
|
||||
derived, never an event_type). ``today`` is the caller-resolved
|
||||
HA-local date.
|
||||
"""
|
||||
return _derived_state(instance, latest, today)
|
||||
|
||||
|
||||
def _was_on_time(
|
||||
instance: QuestInstanceRecord,
|
||||
now: datetime.datetime | None,
|
||||
today: datetime.date | None,
|
||||
) -> bool:
|
||||
"""Return whether the completion moment is on time for ``instance``.
|
||||
|
||||
On ``due_date`` before ``due_time`` is on time; on ``due_date``
|
||||
after ``due_time``, or any later date, is late. With no
|
||||
``due_time``, on time if the completion date equals ``due_date``
|
||||
(or is earlier); late if after ``due_date``. Same-day
|
||||
``due_time`` comparison requires ``now``; UTC time-of-day is never
|
||||
substituted.
|
||||
"""
|
||||
completion_date, completion_time = _completion_moment(now, today)
|
||||
due_date = datetime.date.fromisoformat(instance.due_date)
|
||||
if completion_date < due_date:
|
||||
return True
|
||||
if completion_date > due_date:
|
||||
return False
|
||||
if instance.due_time is None:
|
||||
return True
|
||||
if completion_time is None:
|
||||
raise ValueError("now is required to compare due_time")
|
||||
due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time()
|
||||
return completion_time <= due_time
|
||||
|
||||
|
||||
class _TaskReentrantLock:
|
||||
"""Shared connection lock the owning task may re-enter."""
|
||||
|
||||
def __init__(self, inner: asyncio.Lock) -> None:
|
||||
self._inner = inner
|
||||
self._task: asyncio.Task | None = None
|
||||
self._depth = 0
|
||||
|
||||
def locked(self) -> bool:
|
||||
return self._inner.locked()
|
||||
|
||||
async def acquire(self) -> bool:
|
||||
task = asyncio.current_task()
|
||||
if self._task is task:
|
||||
self._depth += 1
|
||||
return True
|
||||
await self._inner.acquire()
|
||||
self._task = task
|
||||
self._depth = 1
|
||||
return True
|
||||
|
||||
def release(self) -> None:
|
||||
if self._task is not asyncio.current_task():
|
||||
raise RuntimeError("lock released by a non-owner")
|
||||
self._depth -= 1
|
||||
if self._depth == 0:
|
||||
self._task = None
|
||||
self._inner.release()
|
||||
|
||||
async def __aenter__(self) -> _TaskReentrantLock:
|
||||
await self.acquire()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
self.release()
|
||||
|
||||
|
||||
def _reentrant_connection_lock(database) -> _TaskReentrantLock:
|
||||
"""Return the shared connection lock, made task-reentrant.
|
||||
|
||||
``CompletionEventsDao.append`` also acquires this lock, so
|
||||
check+append in :func:`complete_instance` and
|
||||
:func:`uncomplete_instance` must re-enter on the same task. The
|
||||
wrapper is stored in the shared dict so the DAO sees the same
|
||||
object.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _CONNECTION_LOCKS.get(key)
|
||||
if isinstance(lock, _TaskReentrantLock):
|
||||
return lock
|
||||
wrapped = _TaskReentrantLock(
|
||||
lock if lock is not None else asyncio.Lock()
|
||||
)
|
||||
_CONNECTION_LOCKS[key] = wrapped
|
||||
return wrapped
|
||||
|
||||
|
||||
def _validate_int_id(value: object, field: str) -> int:
|
||||
"""Reject non-int ids (bools included) before any lookup."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{field} must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_event_type(value: object) -> str:
|
||||
"""Return a schema-legal event type, or raise naming the field."""
|
||||
if value not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_actor(
|
||||
actor_source: object,
|
||||
actor_user_id: object,
|
||||
actor_child_id: object,
|
||||
) -> tuple[str, str | None, int | None]:
|
||||
"""Return a schema-legal actor triple, or raise naming the field.
|
||||
|
||||
``'user'`` requires a non-empty ``actor_user_id`` string and forbids
|
||||
``actor_child_id``. ``'panel'`` requires an integer
|
||||
``actor_child_id`` and forbids ``actor_user_id``. Any other
|
||||
``actor_source`` (including ``'service'``) is rejected.
|
||||
"""
|
||||
if actor_source not in ("user", "panel"):
|
||||
raise ValueError(
|
||||
f"actor_source must be 'user' or 'panel', got {actor_source!r}"
|
||||
)
|
||||
if actor_source == "user":
|
||||
if not isinstance(actor_user_id, str) or not actor_user_id.strip():
|
||||
raise ValueError("actor_source 'user' requires actor_user_id")
|
||||
if actor_child_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'user' must not carry actor_child_id"
|
||||
)
|
||||
return "user", actor_user_id.strip(), None
|
||||
if actor_user_id is not None:
|
||||
raise ValueError("actor_source 'panel' must not carry actor_user_id")
|
||||
if actor_child_id is None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' requires actor_child_id"
|
||||
)
|
||||
return (
|
||||
"panel",
|
||||
None,
|
||||
_validate_int_id(actor_child_id, "actor_child_id"),
|
||||
)
|
||||
|
||||
|
||||
def _validate_was_on_time(
|
||||
event_type: str, was_on_time: object
|
||||
) -> bool | None:
|
||||
"""Return a real bool or None; completed events require True/False."""
|
||||
if was_on_time is not None and type(was_on_time) is not bool:
|
||||
raise ValueError(
|
||||
f"was_on_time must be True, False or None, got {was_on_time!r}"
|
||||
)
|
||||
if event_type == EVENT_COMPLETED and was_on_time is None:
|
||||
raise ValueError(
|
||||
"a completed event requires was_on_time (True/False)"
|
||||
)
|
||||
return was_on_time
|
||||
|
||||
|
||||
async def append_event(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
child_id: int,
|
||||
event_type: str,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
was_on_time: bool | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
) -> CompletionEventRecord:
|
||||
"""Append one completion or un-completion event and return the row.
|
||||
|
||||
Validates actor shapes and the completed-event on-time requirement
|
||||
before calling :meth:`CompletionEventsDao.append`. ``occurred_at``
|
||||
is stamped here (UTC ``+00:00``); pass ``now`` to pin the clock.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
child_id = _validate_int_id(child_id, "child_id")
|
||||
event_type = _validate_event_type(event_type)
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
was_on_time = _validate_was_on_time(event_type, was_on_time)
|
||||
occurred_at = _stamp_occurred_at(now)
|
||||
return await CompletionEventsDao(database).append(
|
||||
instance_id,
|
||||
child_id,
|
||||
event_type,
|
||||
actor_source,
|
||||
occurred_at,
|
||||
was_on_time,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
)
|
||||
|
||||
|
||||
async def instance_state(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> str:
|
||||
"""Return ``open``, ``done``, or ``missed`` from the latest event.
|
||||
|
||||
No events, or a latest event of uncompleted, is ``open`` unless
|
||||
``due_date`` is before ``today`` (HA-local, threaded), in which
|
||||
case it is ``missed``. A latest event of completed is ``done``
|
||||
even when past due. ``missed`` is never stored as an event_type.
|
||||
Unknown ``instance_id`` raises ValueError naming the field.
|
||||
Existence and the latest event are read inside the connection lock
|
||||
so the answer is one snapshot.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
today_date = _resolve_today(today)
|
||||
async with _connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
return _derived_state(instance, latest, today_date)
|
||||
|
||||
|
||||
class CompletionResult(str):
|
||||
"""The derived instance state, carrying the append verdict.
|
||||
|
||||
A ``str`` subclass so every existing ``result == "done"`` contract
|
||||
keeps working; ``appended`` reports whether THIS call actually
|
||||
appended an event (decided under the same lock as the append), so
|
||||
a caller — the Feature 09 service path firing bus events —
|
||||
announces a transition exactly once even under a concurrent
|
||||
duplicate call where the loser's write is a no-op. A completion
|
||||
also carries the ``was_on_time`` verdict computed under that same
|
||||
lock, so the event payload never re-reads mutable latest state a
|
||||
racing reversal could have replaced.
|
||||
"""
|
||||
|
||||
appended: bool
|
||||
was_on_time: bool | None
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
state: str,
|
||||
*,
|
||||
appended: bool,
|
||||
was_on_time: bool | None = None,
|
||||
) -> "CompletionResult":
|
||||
obj = super().__new__(cls, state)
|
||||
obj.appended = appended
|
||||
obj.was_on_time = was_on_time
|
||||
return obj
|
||||
|
||||
|
||||
async def complete_instance(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> CompletionResult:
|
||||
"""Append a completed event unless the instance is already done.
|
||||
|
||||
Returns the derived state ``done``; ``appended`` reports whether
|
||||
THIS call appended (False on an already-done no-op). Unknown
|
||||
``instance_id`` raises ValueError naming the field. Existence,
|
||||
latest-event, and the append run under one connection lock so a
|
||||
concurrent complete cannot double-append.
|
||||
|
||||
``was_on_time`` is computed from the HA-local completion moment
|
||||
(threaded ``now`` / ``today``) against the instance ``due_date``
|
||||
and optional ``due_time``. ``now`` is the timezone-aware HA-local
|
||||
datetime; its wall clock is never converted to UTC. Same-day
|
||||
``due_time`` comparison requires ``now``.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
async with _reentrant_connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
if latest is not None and latest.event_type == EVENT_COMPLETED:
|
||||
return CompletionResult("done", appended=False)
|
||||
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
# Computed under the completion lock and carried on the result:
|
||||
# the bus event must report THIS completion's timing, never a
|
||||
# re-read of the latest event a racing reversal could replace.
|
||||
on_time = _was_on_time(instance, now, today)
|
||||
await append_event(
|
||||
database,
|
||||
instance.id,
|
||||
instance.child_id,
|
||||
EVENT_COMPLETED,
|
||||
actor_source=actor_source,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
was_on_time=on_time,
|
||||
now=now,
|
||||
)
|
||||
return CompletionResult("done", appended=True, was_on_time=on_time)
|
||||
|
||||
|
||||
async def uncomplete_instance(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
actor_source: str,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
now: datetime.datetime | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> CompletionResult:
|
||||
"""Append an uncompleted event unless the instance is already open.
|
||||
|
||||
Returns the derived state ``open``, or ``missed`` when the instance
|
||||
is past due (``due_date`` before HA-local ``today``); ``appended``
|
||||
reports whether THIS call appended (False on an already-open
|
||||
no-op). Unknown ``instance_id`` raises ValueError naming the
|
||||
field. Existence, latest-event, and the append run under one
|
||||
connection lock so a concurrent uncomplete cannot double-append.
|
||||
The original completed row is not modified.
|
||||
"""
|
||||
instance_id = _validate_int_id(instance_id, "instance_id")
|
||||
actor_source, actor_user_id, actor_child_id = _validate_actor(
|
||||
actor_source, actor_user_id, actor_child_id
|
||||
)
|
||||
today_date = _resolve_today(today)
|
||||
async with _reentrant_connection_lock(database):
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
latest = await CompletionEventsDao(database).get_latest_for_instance(
|
||||
instance_id
|
||||
)
|
||||
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
|
||||
return CompletionResult(
|
||||
_derived_state(instance, latest, today_date),
|
||||
appended=False,
|
||||
)
|
||||
if latest.event_type != EVENT_COMPLETED:
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
|
||||
)
|
||||
latest = await append_event(
|
||||
database,
|
||||
instance.id,
|
||||
instance.child_id,
|
||||
EVENT_UNCOMPLETED,
|
||||
actor_source=actor_source,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
was_on_time=None,
|
||||
now=now,
|
||||
)
|
||||
return CompletionResult(
|
||||
_derived_state(instance, latest, today_date), appended=True
|
||||
)
|
||||
|
||||
|
||||
async def list_missed_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's missed instances due in the closed date range.
|
||||
|
||||
Missed means the latest event state is open and ``due_date`` is
|
||||
before ``today`` (HA-local, threaded). Uses
|
||||
:meth:`QuestInstancesDao.list_by_date_range` plus derived state.
|
||||
Never appends a missed event. Instances and latest events are
|
||||
read inside the connection lock so the list is one snapshot.
|
||||
"""
|
||||
child_id = _validate_int_id(child_id, "child_id")
|
||||
today_date = _resolve_today(today)
|
||||
async with _connection_lock(database):
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, range_start, range_end
|
||||
)
|
||||
events = CompletionEventsDao(database)
|
||||
missed: list[QuestInstanceRecord] = []
|
||||
for instance in instances:
|
||||
latest = await events.get_latest_for_instance(instance.id)
|
||||
if _derived_state(instance, latest, today_date) == "missed":
|
||||
missed.append(instance)
|
||||
return missed
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Constants for the NestQuest integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
DOMAIN = "nestquest"
|
||||
LOGGER_NAME = "custom_components.nestquest"
|
||||
LOGGER = logging.getLogger(LOGGER_NAME)
|
||||
|
||||
#: Strict 24-hour ``HH:MM`` pattern shared by the options flow and the
|
||||
#: settings object's time validation, so a stored option and a freshly
|
||||
#: validated form submit parse the same way. Defined here (HA-free) so
|
||||
#: both the integration's options flow and the core settings module
|
||||
#: import ONE pattern instead of re-copying it.
|
||||
TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
||||
|
||||
# Platforms set up per config entry (Feature 10): the per-child day
|
||||
# sensors and binary sensors.
|
||||
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||
|
||||
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
|
||||
# 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 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"
|
||||
|
||||
# Configuration keys and default values
|
||||
CONF_HORIZON_DAYS = "horizon_days"
|
||||
DEFAULT_HORIZON_DAYS = 14
|
||||
|
||||
CONF_DAY_ROLLOVER_TIME = "day_rollover_time"
|
||||
DEFAULT_DAY_ROLLOVER_TIME = "00:00"
|
||||
|
||||
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT = 300
|
||||
|
||||
# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only
|
||||
# services. Stored as IDs, never usernames (feature guardrail). The
|
||||
# database allowlist is authoritative; the config entry carries the same
|
||||
# list as a disaster-recovery copy so a lost database can be re-seeded.
|
||||
CONF_ADMIN_USER_IDS = "admin_user_ids"
|
||||
|
||||
# Shared coordinator refresh interval (Feature 10), in SECONDS. Every
|
||||
# entity reads one snapshot per refresh; completions push an immediate
|
||||
# refresh so this poll is only the fallback cadence.
|
||||
CONF_UPDATE_INTERVAL = "update_interval"
|
||||
DEFAULT_UPDATE_INTERVAL = 300
|
||||
MIN_UPDATE_INTERVAL = 30
|
||||
|
||||
# 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
|
||||
# strict HH:MM local; each of the four automations has an independent
|
||||
# enable toggle. Per the owner-settled resolution the end-of-day report
|
||||
# defaults to 20:00 — deliberately BEFORE the midnight missed sweep —
|
||||
# and never defaults to the rollover time.
|
||||
CONF_NOTIFY_TARGET = "notify_target"
|
||||
CONF_MORNING_SUMMARY_TIME = "morning_summary_time"
|
||||
DEFAULT_MORNING_SUMMARY_TIME = "08:00"
|
||||
CONF_AFTERNOON_REMINDER_TIME = "afternoon_reminder_time"
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME = "15:00"
|
||||
CONF_END_OF_DAY_REPORT_TIME = "end_of_day_report_time"
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME = "20:00"
|
||||
CONF_MORNING_SUMMARY_ENABLED = "morning_summary_enabled"
|
||||
CONF_AFTERNOON_REMINDER_ENABLED = "afternoon_reminder_enabled"
|
||||
CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled"
|
||||
CONF_CELEBRATION_ENABLED = "celebration_enabled"
|
||||
DEFAULT_AUTOMATION_ENABLED = True
|
||||
|
||||
# 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
|
||||
# configurable later. Boundaries: a window's end equals the next
|
||||
# window's start; classification (Feature 07 materialisation) treats a
|
||||
# due time exactly on the boundary as belonging to the LATER window, so
|
||||
# 12:00 is afternoon (not morning) and 17:00 is evening (not
|
||||
# afternoon). Times after 21:00 belong to no window.
|
||||
WINDOW_MORNING = "morning"
|
||||
WINDOW_AFTERNOON = "afternoon"
|
||||
WINDOW_EVENING = "evening"
|
||||
|
||||
QUEST_WINDOWS: tuple[str, ...] = (
|
||||
WINDOW_MORNING,
|
||||
WINDOW_AFTERNOON,
|
||||
WINDOW_EVENING,
|
||||
)
|
||||
|
||||
#: Inclusive "HH:MM" bounds per window, in 24-hour local time.
|
||||
WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = {
|
||||
WINDOW_MORNING: ("00:00", "11:59"),
|
||||
WINDOW_AFTERNOON: ("12:00", "17:00"),
|
||||
WINDOW_EVENING: ("17:00", "21:00"),
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Typed DAO layer for the children and admin_users tables.
|
||||
|
||||
All SQL for ``children`` and ``admin_users`` lives in this module per
|
||||
the feature guardrail: callers get typed dataclasses back and never see
|
||||
raw rows or SQL. Every method is async and runs through
|
||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
||||
executor and the event loop never blocks.
|
||||
|
||||
Error contract: violations surface as the underlying
|
||||
:class:`sqlite3.IntegrityError` (duplicate, foreign-key, or CHECK
|
||||
failure) from the execute call; the DAO adds no wrapping exception
|
||||
type. A get that matches nothing returns ``None``; a mutation that
|
||||
matches nothing is reported by :class:`~.db.ExecutionResult.rowcount`,
|
||||
which each method returns where the caller may need it (reorder, and
|
||||
the admin remove/set paths).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChildRecord:
|
||||
"""One row of ``children``."""
|
||||
|
||||
id: int
|
||||
display_name: str
|
||||
colour: str | None
|
||||
avatar_ref: str | None
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdminUserRecord:
|
||||
"""One row of ``admin_users``."""
|
||||
|
||||
ha_user_id: str
|
||||
added_at: str
|
||||
|
||||
|
||||
def _child_from_row(row: tuple) -> ChildRecord:
|
||||
"""Build a :class:`ChildRecord` from a SELECT row."""
|
||||
return ChildRecord(
|
||||
id=row[0],
|
||||
display_name=row[1],
|
||||
colour=row[2],
|
||||
avatar_ref=row[3],
|
||||
sort_order=row[4],
|
||||
is_active=bool(row[5]),
|
||||
created_at=row[6],
|
||||
)
|
||||
|
||||
|
||||
_CHILD_COLUMNS = (
|
||||
"id, display_name, colour, avatar_ref, sort_order, is_active, created_at"
|
||||
)
|
||||
|
||||
|
||||
#: One asyncio.Lock per (connection wrapper, running loop), shared by
|
||||
#: every ChildrenDao instance over the same database: the wrapper
|
||||
#: rejects a second concurrent transaction with RuntimeError, so ALL
|
||||
#: transactional DAO operations on one connection must queue here, not
|
||||
#: per-instance. Keyed like the migration runner's lock; entries are
|
||||
#: never evicted (a config entry holds one wrapper per process).
|
||||
_CONNECTION_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _connection_lock(database) -> asyncio.Lock:
|
||||
"""Return the shared transaction-serializing lock for ``database``."""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _CONNECTION_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_CONNECTION_LOCKS[key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
class ChildrenDao:
|
||||
"""Typed async data access for the ``children`` table.
|
||||
|
||||
Deletion is deliberately absent: children are deactivated, never
|
||||
deleted, because completion history references them (feature
|
||||
guardrail).
|
||||
"""
|
||||
|
||||
def __init__(self, database) -> None:
|
||||
self._database = database
|
||||
|
||||
async def create(
|
||||
self,
|
||||
display_name: str,
|
||||
created_at: str,
|
||||
*,
|
||||
colour: str | None = None,
|
||||
avatar_ref: str | None = None,
|
||||
sort_order: int = 0,
|
||||
is_active: bool = True,
|
||||
) -> ChildRecord:
|
||||
"""Insert one child and return the record as stored."""
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO children (display_name, colour, avatar_ref, "
|
||||
"sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
display_name,
|
||||
colour,
|
||||
avatar_ref,
|
||||
sort_order,
|
||||
int(is_active),
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
child = await self.get(result.lastrowid)
|
||||
assert child is not None
|
||||
return child
|
||||
|
||||
async def get(self, child_id: int) -> ChildRecord | None:
|
||||
"""Return the child with ``child_id``, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_CHILD_COLUMNS} FROM children WHERE id = ?",
|
||||
(child_id,),
|
||||
)
|
||||
return _child_from_row(row) if row is not None else None
|
||||
|
||||
async def list_active(self) -> list[ChildRecord]:
|
||||
"""Return active children ordered by sort_order, then id."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_CHILD_COLUMNS} FROM children WHERE is_active = 1 "
|
||||
"ORDER BY sort_order, id"
|
||||
)
|
||||
return [_child_from_row(row) for row in rows]
|
||||
|
||||
async def list_all(self) -> list[ChildRecord]:
|
||||
"""Return all children ordered by sort_order, then id."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_CHILD_COLUMNS} FROM children "
|
||||
"ORDER BY sort_order, id"
|
||||
)
|
||||
return [record for record in map(_child_from_row, rows)]
|
||||
|
||||
async def update(
|
||||
self,
|
||||
child_id: int,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
colour: str | None = None,
|
||||
avatar_ref: str | None = None,
|
||||
sort_order: int | None = None,
|
||||
) -> int:
|
||||
"""Update the given fields; returns rows updated (0 if absent).
|
||||
|
||||
Only non-None arguments are written, so a caller cannot
|
||||
accidentally blank a column by omitting it.
|
||||
"""
|
||||
assignments: list[str] = []
|
||||
parameters: list[object] = []
|
||||
if display_name is not None:
|
||||
assignments.append("display_name = ?")
|
||||
parameters.append(display_name)
|
||||
if colour is not None:
|
||||
assignments.append("colour = ?")
|
||||
parameters.append(colour)
|
||||
if avatar_ref is not None:
|
||||
assignments.append("avatar_ref = ?")
|
||||
parameters.append(avatar_ref)
|
||||
if sort_order is not None:
|
||||
assignments.append("sort_order = ?")
|
||||
parameters.append(sort_order)
|
||||
if not assignments:
|
||||
return 0
|
||||
parameters.append(child_id)
|
||||
result = await self._database.execute(
|
||||
f"UPDATE children SET {', '.join(assignments)} WHERE id = ?",
|
||||
tuple(parameters),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def set_active(self, child_id: int, is_active: bool) -> int:
|
||||
"""Set the child's active flag; returns rows updated (0 if absent)."""
|
||||
result = await self._database.execute(
|
||||
"UPDATE children SET is_active = ? WHERE id = ?",
|
||||
(int(is_active), child_id),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def reorder(self, ordered_ids: list[int]) -> None:
|
||||
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
|
||||
|
||||
The list must be a COMPLETE permutation of the children table:
|
||||
every existing child id appears exactly once. Partial lists
|
||||
(missing an existing child) and unknown ids (naming a child
|
||||
that does not exist) are rejected with ValueError BEFORE any
|
||||
write, leaving the current order untouched — a silent partial
|
||||
reorder would leave unlisted children with stale positions and
|
||||
hide a caller bug. Duplicate ids are rejected up front: they
|
||||
cannot all hold distinct positions, and silently assigning the
|
||||
last occurrence's index would hide a caller bug.
|
||||
|
||||
Runs inside one transaction; positions are staged at offset
|
||||
-len (all negative, so any permutation of the same ids never
|
||||
collides mid-reorder under the wrapper's single-connection
|
||||
serialization), then finalized 0..n-1.
|
||||
|
||||
Concurrent reorder() calls on one connection are serialized by
|
||||
a connection-scoped asyncio lock (shared across every ChildrenDao
|
||||
over the same wrapper, however many instances): the wrapper's
|
||||
transaction() rejects a second concurrent transaction with
|
||||
RuntimeError, so racing callers would crash instead of
|
||||
queueing. The lock makes the second call wait, then run
|
||||
against the first call's final state — which also keeps the
|
||||
completeness check honest: the re-read of the table happens
|
||||
inside the same lock and transaction as the writes.
|
||||
"""
|
||||
if len(set(ordered_ids)) != len(ordered_ids):
|
||||
raise ValueError(
|
||||
"reorder() received duplicate child ids; every id must "
|
||||
"appear at most once"
|
||||
)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT id FROM children"
|
||||
)
|
||||
existing = {row[0] for row in rows}
|
||||
listed = set(ordered_ids)
|
||||
missing = sorted(existing - listed)
|
||||
unknown = sorted(listed - existing)
|
||||
if missing or unknown:
|
||||
details = []
|
||||
if missing:
|
||||
details.append(
|
||||
f"missing child ids {missing} (a partial "
|
||||
"reorder would leave them with stale "
|
||||
"positions)"
|
||||
)
|
||||
if unknown:
|
||||
details.append(
|
||||
f"unknown child ids {unknown}"
|
||||
)
|
||||
raise ValueError(
|
||||
"reorder() must list every child exactly once; "
|
||||
+ "; ".join(details)
|
||||
)
|
||||
offset = len(ordered_ids)
|
||||
await self._database.execute_many(
|
||||
"UPDATE children SET sort_order = ? WHERE id = ?",
|
||||
[
|
||||
(-(offset + index), child_id)
|
||||
for index, child_id in enumerate(ordered_ids)
|
||||
],
|
||||
)
|
||||
await self._database.execute_many(
|
||||
"UPDATE children SET sort_order = ? WHERE id = ?",
|
||||
[
|
||||
(index, child_id)
|
||||
for index, child_id in enumerate(ordered_ids)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class AdminUsersDao:
|
||||
"""Typed async access to the ``admin_users`` allowlist.
|
||||
|
||||
The allowlist stores HA user IDs, never usernames (feature
|
||||
guardrail); an empty allowlist means no admin, enforced by callers
|
||||
resolving through :meth:`exists`.
|
||||
"""
|
||||
|
||||
def __init__(self, database) -> None:
|
||||
self._database = database
|
||||
|
||||
async def add(self, ha_user_id: str, added_at: str) -> bool:
|
||||
"""Add a user to the allowlist; True if inserted, False if present.
|
||||
|
||||
Idempotent under concurrency: a single INSERT ... ON CONFLICT DO
|
||||
NOTHING decides and inserts in one statement (the wrapper
|
||||
serializes statements, not check-then-insert sequences, so a
|
||||
SELECT/INSERT pair would let two concurrent adds both see no
|
||||
row and the second one raise IntegrityError). rowcount reports
|
||||
whether the insert actually happened.
|
||||
"""
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?) "
|
||||
"ON CONFLICT (ha_user_id) DO NOTHING",
|
||||
(ha_user_id, added_at),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def remove(self, ha_user_id: str) -> bool:
|
||||
"""Remove a user from the allowlist; True if a row was removed."""
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM admin_users WHERE ha_user_id = ?",
|
||||
(ha_user_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def list(self) -> list[AdminUserRecord]:
|
||||
"""Return every allowlisted user, oldest first."""
|
||||
rows = await self._database.fetch_all(
|
||||
"SELECT ha_user_id, added_at FROM admin_users "
|
||||
"ORDER BY added_at, ha_user_id"
|
||||
)
|
||||
return [
|
||||
AdminUserRecord(ha_user_id=row[0], added_at=row[1])
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def exists(self, ha_user_id: str) -> bool:
|
||||
"""Return True if ``ha_user_id`` is allowlisted."""
|
||||
row = await self._database.fetch_one(
|
||||
"SELECT 1 FROM admin_users WHERE ha_user_id = ?",
|
||||
(ha_user_id,),
|
||||
)
|
||||
return row is not None
|
||||
@@ -0,0 +1,832 @@
|
||||
"""Typed DAO layer for quest_instances and the append-only completion_events.
|
||||
|
||||
All SQL for these tables lives in this module per the feature
|
||||
guardrails: callers get typed dataclasses back and never see raw rows
|
||||
or SQL. Every method is async and runs through
|
||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
||||
executor and the event loop never blocks.
|
||||
|
||||
Append-only policy (Feature 08, D-005): ``completion_events`` is
|
||||
strictly append-only. This module is where that property is enforced
|
||||
mechanically — the completion-events DAO below exposes NO update or
|
||||
delete method at all; completions append, un-completions append a
|
||||
separate reversal event, and instance state derives from the latest
|
||||
event. A package-wide guard test (bottom of the test module) keeps
|
||||
mutation SQL for this table out of every other module.
|
||||
|
||||
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
|
||||
(definition_id, child_id, due_date, window) — re-running
|
||||
materialization for a tuple whose instance already exists refreshes
|
||||
only the generation-time snapshot columns and never duplicates a row.
|
||||
The schema's UNIQUE(definition_id, child_id, due_date, window) is the
|
||||
last line of defense. The upsert refuses a child that is not an
|
||||
assignee of the definition and a window outside ``const.QUEST_WINDOWS``
|
||||
(D-008 multi-assignee, multi-window model).
|
||||
|
||||
``delete_future_uncompleted`` implements the Feature 06/07 rule that
|
||||
reassignment and schedule edits regenerate future instances: it
|
||||
removes only instances at or after the cutoff that have NO completion
|
||||
event, inside one transaction, so an instance completed mid-call can
|
||||
never be deleted. Instances in the past are never touched (Feature 07
|
||||
guardrail: never delete history), and no method here deletes a single
|
||||
instance that has a completion event.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .dao_rules import _validate_date, _validate_window
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
|
||||
#: One accepted shape bounds lexicographic range queries correctly, so
|
||||
#: the occurred-at scope does not guess at timestamp spellings.
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _today() -> datetime.date:
|
||||
"""Return the current local calendar date (the single date source).
|
||||
|
||||
Every "today" read in this module goes through this one function so
|
||||
the delete clamp and the materialize walk's no-past guard read the
|
||||
same clock, and a caller or test can pin/roll that clock
|
||||
deterministically (e.g. a queued-across-midnight regression). Pure
|
||||
clock read — no business rule lives here.
|
||||
"""
|
||||
return datetime.date.today()
|
||||
|
||||
|
||||
def _resolve_today(today: datetime.date | None) -> datetime.date:
|
||||
"""Return the caller-resolved "today", or the host date when absent.
|
||||
|
||||
The no-past guard and the delete cutoffs must compare due dates
|
||||
against the SAME calendar date the materialization horizon was built
|
||||
from. Home Assistant's configured time zone can lag the host clock
|
||||
around midnight, so when the caller has already resolved the local
|
||||
date (``today``) that anchor is authoritative; ``None`` falls back to
|
||||
the host clock (:func:`_today`), which is exactly the previous
|
||||
behavior for callers with no HA-local date.
|
||||
"""
|
||||
return _today() if today is None else today
|
||||
|
||||
|
||||
def _reject_past(due_date: str, today_iso: str) -> None:
|
||||
"""Raise unless ``due_date`` is on or after ``today_iso``.
|
||||
|
||||
The single no-past rule shared by :meth:`QuestInstancesDao.upsert`
|
||||
and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never
|
||||
generated in the past, measured against the resolved "today".
|
||||
"""
|
||||
if due_date < today_iso:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: due_date "
|
||||
f"{due_date!r} is before today {today_iso!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_utc_timestamp(value: str, field: str) -> None:
|
||||
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
|
||||
|
||||
Same strictness policy as _validate_date: parse with the exact
|
||||
format, then round-trip the parsed value against the input so
|
||||
non-padded or re-ordered shapes are rejected. The round-trip
|
||||
strips the UTC offset (isoformat() omits it when the offset is
|
||||
zero), so the suffix is appended before comparing.
|
||||
"""
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(
|
||||
value, _UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
f"{field} must be a UTC ISO-8601 timestamp "
|
||||
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() + "+00:00" != value:
|
||||
raise ValueError(
|
||||
f"{field} must be a strict UTC timestamp "
|
||||
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _next_day_start(date_str: str) -> str:
|
||||
"""Return the UTC timestamp one second past ``date_str``'s last second.
|
||||
|
||||
Gives range queries an exact half-open upper bound without
|
||||
fractional-second guessing.
|
||||
"""
|
||||
day = datetime.date.fromisoformat(date_str)
|
||||
next_day = day + datetime.timedelta(days=1)
|
||||
return f"{next_day.isoformat()}T00:00:00+00:00"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestInstanceRecord:
|
||||
"""One row of ``quest_instances``."""
|
||||
|
||||
id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
window: str
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
generated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionEventRecord:
|
||||
"""One row of ``completion_events`` (append-only table)."""
|
||||
|
||||
id: int
|
||||
instance_id: int
|
||||
child_id: int
|
||||
event_type: str
|
||||
actor_source: str
|
||||
actor_user_id: str | None
|
||||
actor_child_id: int | None
|
||||
occurred_at: str
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
_INSTANCE_COLUMNS = (
|
||||
"id, definition_id, child_id, window, due_date, due_time, generated_at"
|
||||
)
|
||||
_EVENT_COLUMNS = (
|
||||
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
|
||||
"actor_child_id, occurred_at, was_on_time"
|
||||
)
|
||||
|
||||
#: The only event types the append path accepts; the schema CHECK is
|
||||
#: the backstop, this is the DAO's typed front door.
|
||||
EVENT_COMPLETED = "completed"
|
||||
EVENT_UNCOMPLETED = "uncompleted"
|
||||
|
||||
|
||||
def _instance_from_row(row: tuple) -> QuestInstanceRecord:
|
||||
return QuestInstanceRecord(
|
||||
id=row[0],
|
||||
definition_id=row[1],
|
||||
child_id=row[2],
|
||||
window=row[3],
|
||||
due_date=row[4],
|
||||
due_time=row[5],
|
||||
generated_at=row[6],
|
||||
)
|
||||
|
||||
|
||||
def _event_from_row(row: tuple) -> CompletionEventRecord:
|
||||
return CompletionEventRecord(
|
||||
id=row[0],
|
||||
instance_id=row[1],
|
||||
child_id=row[2],
|
||||
event_type=row[3],
|
||||
actor_source=row[4],
|
||||
actor_user_id=row[5],
|
||||
actor_child_id=row[6],
|
||||
occurred_at=row[7],
|
||||
was_on_time=None if row[8] is None else bool(row[8]),
|
||||
)
|
||||
|
||||
|
||||
class QuestInstancesDao:
|
||||
"""Typed async access to the ``quest_instances`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
due_date: str,
|
||||
generated_at: str,
|
||||
*,
|
||||
window: str,
|
||||
due_time: str | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> QuestInstanceRecord:
|
||||
"""Create the dated instance for the widened key idempotently.
|
||||
|
||||
The instance key is (definition_id, child_id, due_date, window)
|
||||
(D-008): running materialization twice for the same tuple
|
||||
yields ONE row — the ON CONFLICT path refreshes the
|
||||
generation-time snapshot columns (due_time) and regenerates the
|
||||
stamp, but can never create a duplicate. The schema's
|
||||
UNIQUE(definition_id, child_id, due_date, window) backs this
|
||||
up, so the twice-daily case (two windows, one child, one date)
|
||||
yields two rows and the shared case (three assignees, one
|
||||
window) yields three.
|
||||
|
||||
Guardrails enforced here:
|
||||
|
||||
- ``due_date`` must be TODAY or later: instances are never
|
||||
generated in the past (Feature 07). The caller's
|
||||
materialization horizon is future-dated by definition.
|
||||
``today`` optionally pins the resolved HA-local date the
|
||||
guard compares against (falling back to the host clock when
|
||||
omitted) so a household time zone behind the host around
|
||||
midnight is not misread as "past".
|
||||
- ``window`` must be one of ``const.QUEST_WINDOWS``.
|
||||
- An existing instance WITH a completion event is IMMUTABLE:
|
||||
the conflict path must not rewrite its snapshot columns
|
||||
(Feature 07/08). The upsert refuses with ValueError in that
|
||||
case — silently no-oping would mask a materialization bug.
|
||||
- The child must be an assignee of the definition (D-008
|
||||
multi-assignee model).
|
||||
|
||||
The definition must exist (validated atomically under the
|
||||
connection lock so a concurrent definition deactivation or
|
||||
child deletion cannot slip between check and insert).
|
||||
"""
|
||||
_validate_date(due_date, "due_date")
|
||||
_validate_window(window)
|
||||
# Fail fast on obviously-past dates BEFORE queuing on the
|
||||
# connection lock (a nice early error); the AUTHORITATIVE
|
||||
# check runs after the lock is acquired, because a caller can
|
||||
# wait on the lock across midnight — a date that was "today"
|
||||
# at call time may be "yesterday" by the time the INSERT runs.
|
||||
# A caller-supplied ``today`` (HA-local) is used for BOTH
|
||||
# checks and never re-read, since it is already the anchor the
|
||||
# batch runs against.
|
||||
today_date = _resolve_today(today)
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# Re-read "today" under the lock: this is the date the
|
||||
# insert actually executes on, so the no-past rule
|
||||
# holds across midnight rollovers. A pinned HA-local
|
||||
# anchor is exempt from the re-read (see above).
|
||||
if today is None:
|
||||
today_date = _today()
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
definition = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definitions WHERE id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
if definition is None:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} does not exist"
|
||||
)
|
||||
assignee = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ? AND child_id = ?",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
if assignee is None:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} is not "
|
||||
f"assigned to child {child_id}; instance must "
|
||||
"carry an assigned child"
|
||||
)
|
||||
existing = await self._database.fetch_one(
|
||||
"SELECT 1 FROM completion_events "
|
||||
"WHERE instance_id = (SELECT id FROM quest_instances "
|
||||
"WHERE definition_id = ? AND child_id = ? "
|
||||
"AND due_date = ? AND window = ?)",
|
||||
(definition_id, child_id, due_date, window),
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError(
|
||||
f"instance for (definition {definition_id}, "
|
||||
f"child {child_id}, due_date {due_date!r}, "
|
||||
f"window {window!r}) already has a completion "
|
||||
"event and is immutable; it cannot be regenerated"
|
||||
)
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||
"window, due_date, due_time, generated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (definition_id, child_id, due_date, "
|
||||
"window) DO UPDATE SET "
|
||||
"due_time = excluded.due_time, "
|
||||
"generated_at = excluded.generated_at",
|
||||
(
|
||||
definition_id,
|
||||
child_id,
|
||||
window,
|
||||
due_date,
|
||||
due_time,
|
||||
generated_at,
|
||||
),
|
||||
)
|
||||
instance = await self.get(
|
||||
definition_id, child_id, due_date, window
|
||||
)
|
||||
assert instance is not None
|
||||
return instance
|
||||
|
||||
async def upsert_if_valid(
|
||||
self,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
due_date: str,
|
||||
generated_at: str,
|
||||
*,
|
||||
window: str,
|
||||
due_time: str | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> QuestInstanceRecord | None:
|
||||
"""Insert one dated instance iff its tuple is still materializable.
|
||||
|
||||
This is the materialization walk's single write path. Unlike the
|
||||
generic :meth:`upsert`, which validates only existence and
|
||||
assignment, this method re-validates EVERY materialization
|
||||
precondition in the SAME connection-lock-held transaction as the
|
||||
INSERT — closing the check-then-insert gap a concurrent config
|
||||
write could otherwise slip through:
|
||||
|
||||
- the definition exists AND is active (a definition deactivated
|
||||
after the input snapshot is skipped, never written);
|
||||
- the child exists AND is active (a deactivated child is skipped);
|
||||
- the (definition, child) assignment link still exists (a removed
|
||||
assignment is skipped);
|
||||
- the window is still declared on the definition (a removed window
|
||||
is skipped);
|
||||
- the window's live ``due_time`` still EQUALS the caller-supplied
|
||||
``due_time`` (NULL-safe: NULL == NULL counts as equal; a window
|
||||
with no due time matches a snapshot with no due time). A
|
||||
window-time edit landed after the input snapshot makes the
|
||||
caller's ``due_time`` stale, and writing it would overwrite the
|
||||
newer due_time a concurrent regeneration already upserted — so
|
||||
the tuple is skipped instead of written.
|
||||
|
||||
The due_time check closes the one check-then-insert gap this
|
||||
method previously left open: it re-validated every OTHER
|
||||
precondition but then wrote the snapshot's ``due_time``
|
||||
unconditionally, so a stale materialization could clobber a
|
||||
newer, concurrently-regenerated due_time.
|
||||
|
||||
Returns the stored instance, or ``None`` when a precondition
|
||||
failed — the tuple no longer materializable, which the walk SKIPS
|
||||
rather than raising, so the rest of the batch is unaffected. The
|
||||
no-past rule (instances are never generated in the past) is
|
||||
preserved from :meth:`upsert` and still raises ValueError; ``today``
|
||||
optionally pins the HA-local anchor it compares against. An
|
||||
instance that ALREADY has a completion event is also skipped
|
||||
(returns ``None``) rather than raised: it is immutable, so the
|
||||
walk leaves it untouched instead of failing the batch — the
|
||||
generic :meth:`upsert` keeps its hard refusal for the
|
||||
delete/regenerate path.
|
||||
"""
|
||||
_validate_date(due_date, "due_date")
|
||||
_validate_window(window)
|
||||
# Fail fast on obviously-past dates before queuing on the lock;
|
||||
# the authoritative check re-runs under the lock (see upsert).
|
||||
today_date = _resolve_today(today)
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
if today is None:
|
||||
today_date = _today()
|
||||
_reject_past(due_date, today_date.isoformat())
|
||||
still_valid = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definitions d "
|
||||
"JOIN quest_definition_assignees a "
|
||||
"ON a.definition_id = d.id "
|
||||
"JOIN children c ON c.id = a.child_id "
|
||||
"JOIN quest_definition_windows w "
|
||||
"ON w.definition_id = d.id "
|
||||
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
|
||||
"AND d.is_active = 1 AND c.is_active = 1 "
|
||||
"AND w.due_time IS ? LIMIT 1",
|
||||
(definition_id, child_id, window, due_time),
|
||||
)
|
||||
if still_valid is None:
|
||||
# A config change invalidated the tuple since the
|
||||
# snapshot: skip it rather than abort the batch.
|
||||
return None
|
||||
existing = await self._database.fetch_one(
|
||||
"SELECT 1 FROM completion_events "
|
||||
"WHERE instance_id = (SELECT id FROM quest_instances "
|
||||
"WHERE definition_id = ? AND child_id = ? "
|
||||
"AND due_date = ? AND window = ?)",
|
||||
(definition_id, child_id, due_date, window),
|
||||
)
|
||||
if existing is not None:
|
||||
# The instance is already completed and therefore
|
||||
# immutable: the walk SKIPS it (no-op) rather than
|
||||
# rewriting its snapshot columns or failing the
|
||||
# batch. This is the materialization walk's skip,
|
||||
# distinct from the generic :meth:`upsert` refusal.
|
||||
return None
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||
"window, due_date, due_time, generated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (definition_id, child_id, due_date, "
|
||||
"window) DO UPDATE SET "
|
||||
"due_time = excluded.due_time, "
|
||||
"generated_at = excluded.generated_at",
|
||||
(
|
||||
definition_id,
|
||||
child_id,
|
||||
window,
|
||||
due_date,
|
||||
due_time,
|
||||
generated_at,
|
||||
),
|
||||
)
|
||||
instance = await self.get(
|
||||
definition_id, child_id, due_date, window
|
||||
)
|
||||
assert instance is not None
|
||||
return instance
|
||||
|
||||
async def get(
|
||||
self,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
due_date: str,
|
||||
window: str,
|
||||
) -> QuestInstanceRecord | None:
|
||||
"""Return the instance for the widened key tuple, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE definition_id = ? AND child_id = ? AND due_date = ? "
|
||||
"AND window = ?",
|
||||
(definition_id, child_id, due_date, window),
|
||||
)
|
||||
return _instance_from_row(row) if row is not None else None
|
||||
|
||||
async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None:
|
||||
"""Return the instance with ``instance_id``, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?",
|
||||
(instance_id,),
|
||||
)
|
||||
return _instance_from_row(row) if row is not None else None
|
||||
|
||||
async def list_by_child_and_date(
|
||||
self, child_id: int, due_date: str
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's instances due exactly on ``due_date``."""
|
||||
_validate_date(due_date, "due_date")
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE child_id = ? AND due_date = ? ORDER BY id",
|
||||
(child_id, due_date),
|
||||
)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def list_by_date_range(
|
||||
self,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's instances with due_date in the closed range."""
|
||||
_validate_date(range_start, "range_start")
|
||||
_validate_date(range_end, "range_end")
|
||||
if range_end < range_start:
|
||||
raise ValueError(
|
||||
"range_end must be on or after range_start, got "
|
||||
f"{range_end!r} < {range_start!r}"
|
||||
)
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE child_id = ? AND due_date >= ? AND due_date <= ? "
|
||||
"ORDER BY due_date, id",
|
||||
(child_id, range_start, range_end),
|
||||
)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def list_missed_unswept(
|
||||
self, today_iso: str, watermark: str | None
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return past-due open instances the sweep has not announced.
|
||||
|
||||
Feature 11's missed-sweep query: ``due_date`` strictly before
|
||||
``today_iso`` (past due), at or after ``watermark`` when one is
|
||||
recorded, and with NO completion event ever (the Feature 08
|
||||
append-only contract: an instance that was ever completed is
|
||||
never reported missed, even when later reversed — history keeps
|
||||
both rows and the sweep never mutates any). The watermark is
|
||||
the HA-local date the sweep last ran, and the window is
|
||||
``[watermark, today)``: the nightly run entering day D+1 sweeps
|
||||
exactly day D's instances, so every day's quests are announced
|
||||
once — at the midnight after their due date — and a same-night
|
||||
rerun (watermark == today) sees an empty window. Read-only;
|
||||
ordered deterministically by (due_date, id).
|
||||
"""
|
||||
_validate_date(today_iso, "today_iso")
|
||||
if watermark is not None:
|
||||
_validate_date(watermark, "watermark")
|
||||
sql = (
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE due_date < ? "
|
||||
"AND NOT EXISTS ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = quest_instances.id"
|
||||
")"
|
||||
)
|
||||
parameters: tuple = (today_iso,)
|
||||
if watermark is not None:
|
||||
sql += " AND due_date >= ?"
|
||||
parameters += (watermark,)
|
||||
sql += " ORDER BY due_date, id"
|
||||
rows = await self._database.fetch_all(sql, parameters)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def delete_future_uncompleted(
|
||||
self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None
|
||||
) -> int:
|
||||
"""Delete the definition's open instances at/after cutoff.
|
||||
|
||||
Implements regeneration after a definition edit or reassignment
|
||||
(Features 06/07): only instances at or after the cutoff with NO
|
||||
completion event are removed, inside one transaction — an
|
||||
instance that gains a completion event mid-flight cannot be
|
||||
deleted. The cutoff cannot be backdated below today: "future"
|
||||
is measured against TODAY (materialization regenerates the
|
||||
rolling horizon from now), so a caller cannot use this to
|
||||
rewrite past open instances either — the day-rollover missed
|
||||
sweep (Feature 11) handles past-due instances instead. ``today``
|
||||
optionally pins the HA-local anchor the clamp compares against
|
||||
(falling back to the host clock when omitted). Any instance with
|
||||
a completion event is never touched, regardless of its date.
|
||||
Returns the number deleted.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# "today" is read under the lock: the DELETE actually
|
||||
# executes on this date, so a caller queued across
|
||||
# midnight cannot delete instances that became past
|
||||
# while it waited (backdating is clamped to the
|
||||
# execution date, not the call date).
|
||||
today_date = _resolve_today(today)
|
||||
effective_cutoff = max(cutoff_date, today_date.isoformat())
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM quest_instances WHERE definition_id = ? "
|
||||
"AND due_date >= ? AND NOT EXISTS ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = quest_instances.id"
|
||||
")",
|
||||
(definition_id, effective_cutoff),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def delete_future_uncompleted_for_child(
|
||||
self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None
|
||||
) -> tuple[int, str]:
|
||||
"""Delete the child's open instances at/after cutoff, across every
|
||||
definition, returning ``(deleted, effective_cutoff)``.
|
||||
|
||||
Child-scoped counterpart to :meth:`delete_future_uncompleted`,
|
||||
used when a child's presence changes (Feature 09): only instances
|
||||
at or after the cutoff with NO completion event are removed, inside
|
||||
one transaction — an instance that gains a completion event
|
||||
mid-flight cannot be deleted. The cutoff cannot be backdated below
|
||||
today, for the same reason as the definition-scoped method (see its
|
||||
docstring). ``today`` optionally pins the HA-local anchor the clamp
|
||||
compares against (falling back to the host clock when omitted).
|
||||
Any instance with a completion event is never touched, regardless
|
||||
of its date.
|
||||
|
||||
Returns the number deleted and the effective cutoff that was
|
||||
actually applied (``max(cutoff_date, today)``, read under the
|
||||
lock). The caller (:func:`~.materialize.regenerate_for_child`)
|
||||
passes that effective cutoff back as its re-materialization anchor,
|
||||
so the delete and the rebuild share ONE execution-day even when the
|
||||
call is queued across midnight — instead of the caller re-reading a
|
||||
stale "today" and materializing a window the delete already
|
||||
superseded.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# "today" is read under the lock, matching the
|
||||
# definition-scoped method: the DELETE executes on this
|
||||
# date, so a caller queued across midnight cannot delete
|
||||
# instances that became past while it waited.
|
||||
today_date = _resolve_today(today)
|
||||
effective_cutoff = max(cutoff_date, today_date.isoformat())
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM quest_instances WHERE child_id = ? "
|
||||
"AND due_date >= ? AND NOT EXISTS ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = quest_instances.id"
|
||||
")",
|
||||
(child_id, effective_cutoff),
|
||||
)
|
||||
return result.rowcount, effective_cutoff
|
||||
|
||||
|
||||
class CompletionEventsDao:
|
||||
"""Typed append-only access to the ``completion_events`` table.
|
||||
|
||||
APPEND-ONLY BY CONSTRUCTION: this class exposes ``append`` and the
|
||||
three read methods below, and NOTHING else. There is no update,
|
||||
no delete, no upsert — completions and un-completions both arrive
|
||||
through :meth:`append` as new rows, and history is immutable
|
||||
(Feature 08 done-condition: no code path updates or deletes an
|
||||
event). The package-wide leak-guard test additionally fails if
|
||||
mutation SQL for this table ever appears in any module.
|
||||
"""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def append(
|
||||
self,
|
||||
instance_id: int,
|
||||
child_id: int,
|
||||
event_type: str,
|
||||
actor_source: str,
|
||||
occurred_at: str,
|
||||
was_on_time: bool | None,
|
||||
*,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
) -> CompletionEventRecord:
|
||||
"""Append one completion or un-completion event.
|
||||
|
||||
``actor_source`` is 'user' (an admin action: ``actor_user_id``
|
||||
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
|
||||
id, and ``actor_child_id`` carrying the tapped child profile,
|
||||
D-008); the actor-pair CHECKs enforce this shape at the schema
|
||||
level. ``occurred_at`` must be the module's one strict UTC
|
||||
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
|
||||
queries stay exact. ``was_on_time`` is required (True/False)
|
||||
for completions; for un-completions it may be None or the
|
||||
reversed completion's flag (Feature 08 still deciding). The
|
||||
instance, child and — for panel events — the tapped child must
|
||||
exist — validated atomically under the connection lock.
|
||||
"""
|
||||
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {event_type!r}"
|
||||
)
|
||||
if actor_source not in ("user", "panel"):
|
||||
raise ValueError(
|
||||
f"actor_source must be 'user' or 'panel', got "
|
||||
f"{actor_source!r}"
|
||||
)
|
||||
if actor_source == "user" and not actor_user_id:
|
||||
raise ValueError(
|
||||
"actor_source 'user' requires actor_user_id"
|
||||
)
|
||||
if actor_source == "panel" and actor_user_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' must not carry actor_user_id"
|
||||
)
|
||||
if actor_source == "panel" and actor_child_id is None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' requires actor_child_id "
|
||||
"(the tapped child profile)"
|
||||
)
|
||||
if actor_source == "user" and actor_child_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'user' must not carry actor_child_id"
|
||||
)
|
||||
if was_on_time is not None and type(was_on_time) is not bool:
|
||||
raise ValueError(
|
||||
f"was_on_time must be True, False or None, got "
|
||||
f"{was_on_time!r}"
|
||||
)
|
||||
if event_type == EVENT_COMPLETED and was_on_time is None:
|
||||
raise ValueError(
|
||||
"a completed event requires was_on_time (True/False)"
|
||||
)
|
||||
_validate_utc_timestamp(occurred_at, "occurred_at")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
instance = await self._database.fetch_one(
|
||||
"SELECT child_id FROM quest_instances WHERE id = ?",
|
||||
(instance_id,),
|
||||
)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"quest instance {instance_id} does not exist"
|
||||
)
|
||||
if instance[0] != child_id:
|
||||
raise ValueError(
|
||||
f"quest instance {instance_id} belongs to child "
|
||||
f"{instance[0]}, not {child_id}"
|
||||
)
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
if actor_child_id is not None:
|
||||
tapped = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?",
|
||||
(actor_child_id,),
|
||||
)
|
||||
if tapped is None:
|
||||
raise ValueError(
|
||||
f"actor_child_id {actor_child_id} does not "
|
||||
"reference an existing child"
|
||||
)
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO completion_events (instance_id, child_id, "
|
||||
"event_type, actor_source, actor_user_id, "
|
||||
"actor_child_id, occurred_at, was_on_time) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
instance_id,
|
||||
child_id,
|
||||
event_type,
|
||||
actor_source,
|
||||
actor_user_id,
|
||||
actor_child_id,
|
||||
occurred_at,
|
||||
None if was_on_time is None else int(was_on_time),
|
||||
),
|
||||
)
|
||||
event = await self._database.fetch_one(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE id = ?",
|
||||
(result.lastrowid,),
|
||||
)
|
||||
assert event is not None
|
||||
return _event_from_row(event)
|
||||
|
||||
async def list_by_instance(
|
||||
self, instance_id: int
|
||||
) -> list[CompletionEventRecord]:
|
||||
"""Return every event for the instance, in append order."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE instance_id = ? ORDER BY id",
|
||||
(instance_id,),
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
|
||||
async def list_by_child_and_date_range(
|
||||
self,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
*,
|
||||
due_date_scope: bool = True,
|
||||
) -> list[CompletionEventRecord]:
|
||||
"""Return the child's events for instances due in the closed range.
|
||||
|
||||
Scopes by the INSTANCE's due date (the day the task was owed),
|
||||
not the event's wall-clock timestamp: history reporting asks
|
||||
"what happened to the tasks due that day" (Feature 13). Set
|
||||
``due_date_scope=False`` to filter by ``occurred_at`` date
|
||||
instead. Ordered by instance, then event id.
|
||||
"""
|
||||
_validate_date(range_start, "range_start")
|
||||
_validate_date(range_end, "range_end")
|
||||
if range_end < range_start:
|
||||
raise ValueError(
|
||||
"range_end must be on or after range_start, got "
|
||||
f"{range_end!r} < {range_start!r}"
|
||||
)
|
||||
if due_date_scope:
|
||||
predicate = (
|
||||
"SELECT id FROM quest_instances WHERE child_id = ? "
|
||||
"AND due_date >= ? AND due_date <= ?"
|
||||
)
|
||||
parameters: list[object] = [child_id, range_start, range_end]
|
||||
else:
|
||||
# Occurred-at scope: occurred_at is validated to the one
|
||||
# accepted UTC shape at append time, so half-open day
|
||||
# bounds in that same shape are exact (no spelling drift).
|
||||
occurred_start = f"{range_start}T00:00:00+00:00"
|
||||
occurred_end = f"{range_end}T23:59:59+00:00"
|
||||
query = (
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE child_id = ? AND occurred_at >= ? "
|
||||
"AND occurred_at < ? ORDER BY instance_id, id"
|
||||
)
|
||||
rows = await self._database.fetch_all(
|
||||
query,
|
||||
[
|
||||
child_id,
|
||||
occurred_start,
|
||||
_next_day_start(range_end),
|
||||
],
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
f"WHERE instance_id IN ({predicate}) "
|
||||
"ORDER BY instance_id, id",
|
||||
tuple(parameters),
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
|
||||
async def get_latest_for_instance(
|
||||
self, instance_id: int
|
||||
) -> CompletionEventRecord | None:
|
||||
"""Return the instance's most recent event, or None if untouched.
|
||||
|
||||
Instance current state derives from this event (Feature 08):
|
||||
'completed' means done, 'uncompleted' as the latest means open
|
||||
again, None means never completed.
|
||||
"""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE instance_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(instance_id,),
|
||||
)
|
||||
return _event_from_row(row) if row is not None else None
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Typed DAO for the integration-owned ``nestquest_meta_state`` table.
|
||||
|
||||
Generic persistent key/value bookkeeping that belongs to the
|
||||
integration itself rather than any domain table — currently the
|
||||
Feature 11 missed-sweep watermark. Values are plain strings; callers
|
||||
interpret them. All SQL for this table lives here per the feature
|
||||
guardrails, and every method runs through
|
||||
:class:`~.db.NestQuestDatabase` so nothing blocks the event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
_TABLE = "nestquest_meta_state"
|
||||
|
||||
|
||||
class MetaStateDao:
|
||||
"""Typed async access to the ``nestquest_meta_state`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Return the stored value for ``key``, or None when absent."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT value FROM {_TABLE} WHERE key = ?", (key,)
|
||||
)
|
||||
return row[0] if row is not None else None
|
||||
|
||||
async def set(self, key: str, value: str) -> None:
|
||||
"""Upsert one key/value row atomically.
|
||||
|
||||
The write runs inside one transaction under the shared
|
||||
connection lock, so a racing reader either sees the old value
|
||||
or the fully-written new one, never a half-applied state.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
await self._database.execute(
|
||||
f"INSERT INTO {_TABLE} (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Typed DAO layer for the presence_schedules and presence_overrides tables.
|
||||
|
||||
All SQL for these tables lives in this module per the feature
|
||||
guardrails: callers get typed dataclasses back and never see raw rows
|
||||
or SQL. Every method is async and runs through
|
||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
||||
executor and the event loop never blocks.
|
||||
|
||||
Semantics per the done-condition and Feature 05:
|
||||
|
||||
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting
|
||||
by child updates the existing row rather than raising.
|
||||
- A child with NO schedule row is present every day — the absence of a
|
||||
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child`
|
||||
returning None, never by an empty pattern row.
|
||||
- Overrides beat the pattern for their date range (that resolution is
|
||||
the presence engine's job, Feature 05); the DAO only stores and
|
||||
lists them. A single-day override stores the same date as both
|
||||
start and end (schema CHECK requires end >= start).
|
||||
|
||||
Date arguments are validated as strict YYYY-MM-DD per the same policy
|
||||
the rules DAO applies: the schema's lexical CHECKs cannot prove
|
||||
calendar validity.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .dao_rules import _UNSET, _validate_date
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PresenceScheduleRecord:
|
||||
"""One row of ``presence_schedules``."""
|
||||
|
||||
id: int
|
||||
child_id: int
|
||||
cycle_length_weeks: int
|
||||
anchor_date: str
|
||||
pattern: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PresenceOverrideRecord:
|
||||
"""One row of ``presence_overrides``."""
|
||||
|
||||
id: int
|
||||
child_id: int
|
||||
start_date: str
|
||||
end_date: str
|
||||
is_present: bool
|
||||
note: str | None
|
||||
|
||||
|
||||
_SCHEDULE_COLUMNS = (
|
||||
"id, child_id, cycle_length_weeks, anchor_date, pattern"
|
||||
)
|
||||
_OVERRIDE_COLUMNS = (
|
||||
"id, child_id, start_date, end_date, is_present, note"
|
||||
)
|
||||
|
||||
|
||||
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord:
|
||||
return PresenceScheduleRecord(
|
||||
id=row[0],
|
||||
child_id=row[1],
|
||||
cycle_length_weeks=row[2],
|
||||
anchor_date=row[3],
|
||||
pattern=row[4],
|
||||
)
|
||||
|
||||
|
||||
def _override_from_row(row: tuple) -> PresenceOverrideRecord:
|
||||
return PresenceOverrideRecord(
|
||||
id=row[0],
|
||||
child_id=row[1],
|
||||
start_date=row[2],
|
||||
end_date=row[3],
|
||||
is_present=bool(row[4]),
|
||||
note=row[5],
|
||||
)
|
||||
|
||||
|
||||
class PresenceSchedulesDao:
|
||||
"""Typed async access to the ``presence_schedules`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def upsert_by_child(
|
||||
self,
|
||||
child_id: int,
|
||||
cycle_length_weeks: int,
|
||||
anchor_date: str,
|
||||
pattern: str,
|
||||
) -> PresenceScheduleRecord:
|
||||
"""Set the child's presence schedule, replacing any existing one.
|
||||
|
||||
Upsert semantics are the done-condition: a second schedule for
|
||||
the same child updates the existing row rather than raising.
|
||||
Runs under the connection lock so a concurrent upsert/delete
|
||||
for the same child serializes instead of colliding on the
|
||||
schema's UNIQUE(child_id). Pattern validity (segment shape,
|
||||
segment count = cycle_length_weeks) is enforced by the schema
|
||||
CHECKs; the child must exist (FK) — validated atomically here
|
||||
so a deleted child cannot pass a stale check.
|
||||
"""
|
||||
_validate_date(anchor_date, "anchor_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
await self._database.execute(
|
||||
"INSERT INTO presence_schedules (child_id, "
|
||||
"cycle_length_weeks, anchor_date, pattern) "
|
||||
"VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT (child_id) DO UPDATE SET "
|
||||
"cycle_length_weeks = excluded.cycle_length_weeks, "
|
||||
"anchor_date = excluded.anchor_date, "
|
||||
"pattern = excluded.pattern",
|
||||
(child_id, cycle_length_weeks, anchor_date, pattern),
|
||||
)
|
||||
schedule = await self.get_by_child(child_id)
|
||||
assert schedule is not None
|
||||
return schedule
|
||||
|
||||
async def get_by_child(
|
||||
self, child_id: int
|
||||
) -> PresenceScheduleRecord | None:
|
||||
"""Return the child's schedule, or None when the child has none.
|
||||
|
||||
None is meaningful and UNAMBIGUOUS here: the child exists and
|
||||
has no schedule row, i.e. the child is present every day
|
||||
(Feature 05 guardrail). An unknown child id raises ValueError
|
||||
instead of returning None, because "nonexistent child" must
|
||||
never be silently read as "present every day".
|
||||
"""
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
|
||||
"WHERE child_id = ?",
|
||||
(child_id,),
|
||||
)
|
||||
return _schedule_from_row(row) if row is not None else None
|
||||
|
||||
async def delete(self, child_id: int) -> bool:
|
||||
"""Remove the child's schedule; True if a row was removed.
|
||||
|
||||
After deletion the child is present every day (no row means
|
||||
no restriction). Deleting a schedule the child never had
|
||||
returns False.
|
||||
"""
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM presence_schedules WHERE child_id = ?",
|
||||
(child_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
class PresenceOverridesDao:
|
||||
"""Typed async access to the ``presence_overrides`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def create(
|
||||
self,
|
||||
child_id: int,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
is_present: bool,
|
||||
*,
|
||||
note: str | None = None,
|
||||
) -> PresenceOverrideRecord:
|
||||
"""Insert one override and return the record as stored.
|
||||
|
||||
The child must exist (validated atomically under the
|
||||
connection lock). Date shape is validated strictly, and the
|
||||
schema CHECK rejects end < start.
|
||||
|
||||
Overlap policy (Feature 05): an override for a child may not
|
||||
overlap another override of the SAME child — two different
|
||||
present/absent answers for one date are unresolvable. The
|
||||
overlap check (inclusive boundaries) runs inside the same
|
||||
transaction as the INSERT, so a racing create cannot slip a
|
||||
conflicting override between the check and the write; a
|
||||
conflict raises ValueError naming the conflicting override's
|
||||
id and range.
|
||||
"""
|
||||
_validate_date(start_date, "start_date")
|
||||
_validate_date(end_date, "end_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
# Only a well-ordered range has overlap semantics:
|
||||
# inverted ranges fall through to the INSERT and hit
|
||||
# the schema's end>=start CHECK, preserving the
|
||||
# IntegrityError contract the matrix tests rely on.
|
||||
if end_date >= start_date:
|
||||
conflicting = await self._database.fetch_one(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} "
|
||||
"FROM presence_overrides "
|
||||
"WHERE child_id = ? AND end_date >= ? "
|
||||
"AND start_date <= ? ORDER BY id LIMIT 1",
|
||||
(child_id, start_date, end_date),
|
||||
)
|
||||
else:
|
||||
conflicting = None
|
||||
if conflicting is not None:
|
||||
conflict = _override_from_row(conflicting)
|
||||
raise ValueError(
|
||||
f"override for child {child_id} "
|
||||
f"[{start_date}, {end_date}] overlaps existing "
|
||||
f"override {conflict.id} "
|
||||
f"[{conflict.start_date}, {conflict.end_date}] "
|
||||
f"(is_present={conflict.is_present})"
|
||||
)
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO presence_overrides (child_id, "
|
||||
"start_date, end_date, is_present, note) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(child_id, start_date, end_date, int(is_present), note),
|
||||
)
|
||||
override = await self._database.fetch_one(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
"WHERE id = ?",
|
||||
(result.lastrowid,),
|
||||
)
|
||||
assert override is not None
|
||||
return _override_from_row(override)
|
||||
|
||||
async def list_by_child_and_range(
|
||||
self,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
) -> list[PresenceOverrideRecord]:
|
||||
"""Return the child's overrides overlapping [range_start, range_end].
|
||||
|
||||
An override overlaps the range when it ends on or after the
|
||||
range starts and starts on or before the range ends (inclusive
|
||||
boundary dates, matching the storage model where overrides are
|
||||
closed date ranges). Ordered by start date.
|
||||
"""
|
||||
_validate_date(range_start, "range_start")
|
||||
_validate_date(range_end, "range_end")
|
||||
if range_end < range_start:
|
||||
raise ValueError(
|
||||
"range_end must be on or after range_start, got "
|
||||
f"{range_end!r} < {range_start!r}"
|
||||
)
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
"WHERE child_id = ? AND end_date >= ? AND start_date <= ? "
|
||||
"ORDER BY start_date",
|
||||
(child_id, range_start, range_end),
|
||||
)
|
||||
return [_override_from_row(row) for row in rows]
|
||||
|
||||
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
|
||||
"""Return one override by id, or None when it does not exist."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
"WHERE id = ?",
|
||||
(override_id,),
|
||||
)
|
||||
return _override_from_row(row) if row is not None else None
|
||||
|
||||
async def delete(self, override_id: int) -> bool:
|
||||
"""Remove one override by id; True if a row was removed."""
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM presence_overrides WHERE id = ?",
|
||||
(override_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def read_snapshot_unlocked(
|
||||
database: NestQuestDatabase,
|
||||
child_ids: list[int],
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
) -> tuple[
|
||||
dict[int, PresenceScheduleRecord],
|
||||
dict[int, list[PresenceOverrideRecord]],
|
||||
]:
|
||||
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
|
||||
|
||||
MUST be called inside the connection lock and an open transaction, so
|
||||
the schedules and overrides returned here stay coherent with every
|
||||
other table the caller reads inside that same transaction (the
|
||||
materialization walk's single input snapshot does exactly that). The
|
||||
date range comes already validated by the caller.
|
||||
|
||||
Returns ``(schedules, overrides)``:
|
||||
|
||||
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
|
||||
for every child that HAS a schedule row. A child absent from the
|
||||
mapping has no schedule, i.e. is present every day (Feature 05).
|
||||
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
|
||||
``[range_start, range_end]`` (inclusive boundaries), ordered by start
|
||||
date; a child with no matching override is absent from the mapping.
|
||||
|
||||
``child_ids`` must name existing children (the caller derives them
|
||||
from a trusted snapshot, within the same transaction).
|
||||
"""
|
||||
children = sorted(set(child_ids))
|
||||
if not children:
|
||||
return {}, {}
|
||||
placeholders = ",".join("?" for _ in children)
|
||||
schedule_rows = await database.fetch_all(
|
||||
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
|
||||
f"WHERE child_id IN ({placeholders})",
|
||||
tuple(children),
|
||||
)
|
||||
override_rows = await database.fetch_all(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
f"WHERE child_id IN ({placeholders}) AND end_date >= ? "
|
||||
"AND start_date <= ? ORDER BY start_date",
|
||||
tuple(children) + (range_start, range_end),
|
||||
)
|
||||
schedules = {
|
||||
record.child_id: record
|
||||
for record in map(_schedule_from_row, schedule_rows)
|
||||
}
|
||||
overrides: dict[int, list[PresenceOverrideRecord]] = {}
|
||||
for row in override_rows:
|
||||
record = _override_from_row(row)
|
||||
overrides.setdefault(record.child_id, []).append(record)
|
||||
return schedules, overrides
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,436 @@
|
||||
"""Async SQLite connection wrapper for the NestQuest integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
#: Executor contract: schedule a synchronous callable on a worker
|
||||
#: thread/futures pool and await its result. Mirrors the shape of
|
||||
#: ``hass.async_add_executor_job`` so the integration wires it in with a
|
||||
#: bare method reference, but this package never imports Home Assistant.
|
||||
Executor = Callable[..., Awaitable[T]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionResult:
|
||||
"""Plain-data outcome of one statement.
|
||||
|
||||
``rowcount`` and ``lastrowid`` mirror the corresponding
|
||||
:class:`sqlite3.Cursor` attributes; both are captured on the executor
|
||||
thread so no sqlite3 object ever crosses back to the event loop.
|
||||
"""
|
||||
|
||||
rowcount: int
|
||||
lastrowid: int | None
|
||||
|
||||
|
||||
class NestQuestDatabase:
|
||||
"""Async wrapper around a single SQLite connection.
|
||||
|
||||
Every sqlite3 call is delegated to a worker through the ``executor``
|
||||
callable passed at construction (mirroring the contract of
|
||||
``hass.async_add_executor_job``) so the event loop is never blocked,
|
||||
and only plain Python data (row tuples and
|
||||
:class:`ExecutionResult`) is returned to the event loop.
|
||||
|
||||
Every operation is serialized through one asyncio.Lock per wrapper. A
|
||||
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
|
||||
interleaved coroutines cannot corrupt the transaction and concurrent
|
||||
BEGINs are impossible; statements issued while a transaction is open
|
||||
queue until it completes. Concurrent transactions on one wrapper are
|
||||
not supported: a second ``transaction()`` entered while one is active
|
||||
raises :class:`RuntimeError`. ``close()`` serializes on the same lock
|
||||
and is idempotent.
|
||||
|
||||
Every connection-affecting executor job (connect, pragmas, statements,
|
||||
transaction lifecycle and close) is awaited through the settlement
|
||||
helper :meth:`_await_settled`: if the awaiting task is cancelled while
|
||||
a job is in flight, the job still settles on the worker thread before
|
||||
the cancellation propagates, so the wrapper lock is never released
|
||||
while the executor thread is mid-job.
|
||||
"""
|
||||
|
||||
def __init__(self, executor: Callable[..., Awaitable[T]]) -> None:
|
||||
"""Initialize the wrapper around an executor callable.
|
||||
|
||||
``executor`` schedules a synchronous callable on a worker and
|
||||
returns an awaitable of its result — the contract of
|
||||
``hass.async_add_executor_job``. The integration passes that
|
||||
method directly; tests pass a helper backed by
|
||||
:func:`asyncio.to_thread` or an inline runner.
|
||||
"""
|
||||
self._executor: Callable[..., Awaitable[Any]] = executor
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
self._rowcount: int = -1
|
||||
self._lastrowid: int | None = None
|
||||
self._lock: asyncio.Lock | None = None
|
||||
self._lock_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._in_transaction = False
|
||||
self._tx_task: asyncio.Task | None = None
|
||||
|
||||
def _get_lock(self) -> asyncio.Lock:
|
||||
"""Return the per-wrapper lock, (re)bound to the running loop.
|
||||
|
||||
HA drives every wrapper call from one event loop, so the lock is
|
||||
created once and reused; re-binding only when the running loop
|
||||
differs keeps the wrapper usable from plain synchronous drivers
|
||||
that run each call on a fresh loop.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
if self._lock is None or self._lock_loop is not loop:
|
||||
self._lock = asyncio.Lock()
|
||||
self._lock_loop = loop
|
||||
return self._lock
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Return True while the underlying connection is open."""
|
||||
return self._conn is not None
|
||||
|
||||
@property
|
||||
def rowcount(self) -> int:
|
||||
"""Row count of the most recent statement, or -1 when unset."""
|
||||
return self._rowcount
|
||||
|
||||
@property
|
||||
def lastrowid(self) -> int | None:
|
||||
"""Row id of the most recent insert, or None when unset."""
|
||||
return self._lastrowid
|
||||
|
||||
@asynccontextmanager
|
||||
async def _serialized(self) -> AsyncIterator[None]:
|
||||
"""Hold the wrapper lock unless the caller owns the active transaction.
|
||||
|
||||
A transaction holds the lock for its whole BEGIN..COMMIT span;
|
||||
statements issued by the transaction's own task are already
|
||||
serialized by it and must not re-acquire the non-reentrant lock.
|
||||
Any other task queues on the lock until the transaction ends.
|
||||
"""
|
||||
if (
|
||||
self._in_transaction
|
||||
and self._tx_task is not None
|
||||
and asyncio.current_task() is self._tx_task
|
||||
):
|
||||
yield
|
||||
return
|
||||
async with self._get_lock():
|
||||
yield
|
||||
|
||||
async def open(self, path: Path | str) -> NestQuestDatabase:
|
||||
"""Open the SQLite file with WAL journal mode and foreign keys on.
|
||||
|
||||
Cancellation-safe: if this call is cancelled while the connect job
|
||||
is in flight, the job still settles (see :meth:`_await_settled`).
|
||||
A connection the settled job created is never adopted by the
|
||||
cancelled call; it is closed through a settled executor job instead
|
||||
of being abandoned to garbage collection, and the cancellation
|
||||
propagates afterwards, so a follow-up ``open()`` starts from a
|
||||
clean state.
|
||||
"""
|
||||
if self._conn is not None:
|
||||
return self
|
||||
async with self._get_lock():
|
||||
if self._conn is not None:
|
||||
return self
|
||||
|
||||
def _open() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(
|
||||
path, check_same_thread=False, isolation_level=None
|
||||
)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
except BaseException:
|
||||
conn.close()
|
||||
raise
|
||||
return conn
|
||||
|
||||
settled, cancelled = await self._await_settled_state(
|
||||
self._executor(_open)
|
||||
)
|
||||
if cancelled:
|
||||
if isinstance(settled, sqlite3.Connection):
|
||||
|
||||
def _close_abandoned() -> None:
|
||||
settled.close()
|
||||
|
||||
await self._await_settled(
|
||||
self._executor(_close_abandoned)
|
||||
)
|
||||
raise asyncio.CancelledError()
|
||||
self._conn = settled
|
||||
return self
|
||||
|
||||
async def execute(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> ExecutionResult:
|
||||
"""Run one statement in the executor and return its plain result."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _execute() -> ExecutionResult:
|
||||
cursor = conn.execute(sql, parameters)
|
||||
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
||||
|
||||
result = await self._await_settled(
|
||||
self._executor(_execute)
|
||||
)
|
||||
self._rowcount = result.rowcount
|
||||
self._lastrowid = result.lastrowid
|
||||
return result
|
||||
|
||||
async def execute_many(
|
||||
self, sql: str, parameters: Sequence[Sequence[Any]]
|
||||
) -> ExecutionResult:
|
||||
"""Run executemany in the executor and return its plain result."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _execute_many() -> ExecutionResult:
|
||||
cursor = conn.executemany(sql, parameters)
|
||||
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
||||
|
||||
result = await self._await_settled(
|
||||
self._executor(_execute_many)
|
||||
)
|
||||
self._rowcount = result.rowcount
|
||||
self._lastrowid = result.lastrowid
|
||||
return result
|
||||
|
||||
async def fetch_one(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> tuple | None:
|
||||
"""Run a query in the executor and return its first row, or None."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _fetch_one() -> tuple[tuple | None, int, int | None]:
|
||||
cursor = conn.execute(sql, parameters)
|
||||
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
|
||||
|
||||
row, rowcount, lastrowid = await self._await_settled(
|
||||
self._executor(_fetch_one)
|
||||
)
|
||||
self._rowcount = rowcount
|
||||
self._lastrowid = lastrowid
|
||||
return row
|
||||
|
||||
async def fetch_all(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> list[tuple]:
|
||||
"""Run a query in the executor and return all of its rows."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _fetch_all() -> tuple[list[tuple], int, int | None]:
|
||||
cursor = conn.execute(sql, parameters)
|
||||
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
|
||||
|
||||
rows, rowcount, lastrowid = await self._await_settled(
|
||||
self._executor(_fetch_all)
|
||||
)
|
||||
self._rowcount = rowcount
|
||||
self._lastrowid = lastrowid
|
||||
return rows
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self) -> AsyncIterator[None]:
|
||||
"""Run a block inside BEGIN/COMMIT, rolling back on any exception.
|
||||
|
||||
The wrapper lock is held for the entire BEGIN..COMMIT span, so no
|
||||
other statement can interleave inside the transaction. Entering a
|
||||
second transaction while one is active raises RuntimeError. A
|
||||
failing COMMIT (e.g. a deferred foreign-key violation) also triggers
|
||||
a ROLLBACK before the commit error propagates, so the real
|
||||
connection is never left inside a stray transaction.
|
||||
|
||||
The lifecycle is cancellation-safe. Transaction ownership and the
|
||||
wrapper lock are only released after the executor work has fully
|
||||
settled, even when the owning task is cancelled mid-flight: if a
|
||||
cancelled BEGIN or COMMIT still completes on the worker thread,
|
||||
the corresponding ROLLBACK is shielded and awaited before any state
|
||||
is cleared. A lifecycle statement that fails plus a failing
|
||||
follow-up cleanup surface both errors in an ExceptionGroup.
|
||||
"""
|
||||
if self._in_transaction:
|
||||
raise RuntimeError(
|
||||
"A NestQuest transaction is already active on this "
|
||||
"connection; concurrent transactions are not supported"
|
||||
)
|
||||
async with self._get_lock():
|
||||
conn = self._require_conn()
|
||||
self._in_transaction = True
|
||||
self._tx_task = asyncio.current_task()
|
||||
try:
|
||||
|
||||
def _begin() -> None:
|
||||
conn.execute("BEGIN")
|
||||
|
||||
await self._settle_lifecycle(
|
||||
conn,
|
||||
self._executor(_begin),
|
||||
"BEGIN did not settle cleanly and the follow-up "
|
||||
"cleanup also raised; the transaction may need manual "
|
||||
"cleanup",
|
||||
)
|
||||
try:
|
||||
yield
|
||||
except BaseException:
|
||||
|
||||
def _rollback() -> None:
|
||||
conn.execute("ROLLBACK")
|
||||
|
||||
await self._settle_lifecycle(
|
||||
conn,
|
||||
self._executor(_rollback),
|
||||
"ROLLBACK failed and the follow-up cleanup also "
|
||||
"raised; the transaction may need manual cleanup",
|
||||
)
|
||||
raise
|
||||
|
||||
def _commit() -> None:
|
||||
conn.execute("COMMIT")
|
||||
|
||||
await self._settle_lifecycle(
|
||||
conn,
|
||||
self._executor(_commit),
|
||||
"COMMIT failed and the follow-up cleanup also raised; "
|
||||
"the transaction may need manual cleanup",
|
||||
)
|
||||
finally:
|
||||
self._in_transaction = False
|
||||
self._tx_task = None
|
||||
|
||||
async def _settle_lifecycle(
|
||||
self, conn: sqlite3.Connection, job: Awaitable[Any], message: str
|
||||
) -> None:
|
||||
"""Run one BEGIN/ROLLBACK/COMMIT job, then force the connection clean.
|
||||
|
||||
The job always settles first (see :meth:`_await_settled`), so a
|
||||
cancelled statement that the worker thread completed anyway is
|
||||
never abandoned mid-flight. If the job raised, a follow-up
|
||||
ROLLBACK — itself settled — guarantees the connection ends outside
|
||||
any SQLite transaction before the error propagates. When both the
|
||||
job and the cleanup raise, both errors are preserved in an
|
||||
ExceptionGroup instead of the original being lost.
|
||||
"""
|
||||
try:
|
||||
await self._await_settled(job)
|
||||
except BaseException as settle_error:
|
||||
errors: list[BaseException] = [settle_error]
|
||||
try:
|
||||
await self._ensure_no_stray_transaction(conn)
|
||||
except BaseException as cleanup_error:
|
||||
errors.append(cleanup_error)
|
||||
if len(errors) == 2:
|
||||
raise BaseExceptionGroup(message, errors) from settle_error
|
||||
raise
|
||||
|
||||
async def _ensure_no_stray_transaction(
|
||||
self, conn: sqlite3.Connection
|
||||
) -> None:
|
||||
"""Force the connection out of any transaction via ROLLBACK.
|
||||
|
||||
Runs after a failed or cancelled lifecycle statement. The worker
|
||||
thread may still be finishing (or may already have completed) the
|
||||
very statement whose await was cancelled, so both the check and
|
||||
the ROLLBACK settle in the executor before the caller releases
|
||||
transaction ownership.
|
||||
"""
|
||||
|
||||
def _cleanup() -> bool:
|
||||
if conn.in_transaction:
|
||||
conn.execute("ROLLBACK")
|
||||
return conn.in_transaction
|
||||
|
||||
stray = await self._await_settled(
|
||||
self._executor(_cleanup)
|
||||
)
|
||||
if stray:
|
||||
raise RuntimeError(
|
||||
"The SQLite connection is still inside a transaction after "
|
||||
"cleanup; the transaction may need manual cleanup"
|
||||
)
|
||||
|
||||
async def _await_settled(self, job: Awaitable[Any]) -> Any:
|
||||
"""Await ``job``, letting it settle even if this task is cancelled.
|
||||
|
||||
Cancellation is observed, not swallowed: it is re-raised only once
|
||||
the job has fully settled, so the caller can finalize against a
|
||||
known connection state instead of abandoning in-flight work. The
|
||||
job is wrapped in a task because a bare coroutine dies un-awaited
|
||||
once its await is cancelled. A second cancellation during
|
||||
settlement is absorbed the same way; if the job itself fails after
|
||||
a cancellation, both errors surface in an ExceptionGroup.
|
||||
"""
|
||||
result, cancelled = await self._await_settled_state(job)
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError()
|
||||
return result
|
||||
|
||||
async def _await_settled_state(self, job: Awaitable[Any]) -> tuple[Any, bool]:
|
||||
"""Await ``job`` to settlement and report ``(result, cancelled)``.
|
||||
|
||||
Same settlement guarantees as :meth:`_await_settled`, but returns
|
||||
the settled outcome together with whether the awaiting task was
|
||||
cancelled, so callers that must act on a settled result before
|
||||
re-raising cancellation (e.g. closing an abandoned connection)
|
||||
can do so without losing the result.
|
||||
"""
|
||||
cancelled = False
|
||||
inner = asyncio.ensure_future(job)
|
||||
while not inner.done():
|
||||
try:
|
||||
await asyncio.shield(inner)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
try:
|
||||
result = inner.result()
|
||||
except BaseException as job_error:
|
||||
if cancelled:
|
||||
raise BaseExceptionGroup(
|
||||
"executor job failed while settling after cancellation",
|
||||
[asyncio.CancelledError(), job_error],
|
||||
) from job_error
|
||||
raise
|
||||
return result, cancelled
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the connection in the executor; safe to call repeatedly.
|
||||
|
||||
Serialized on the wrapper lock, so an in-flight operation or open
|
||||
transaction always completes before the connection is closed.
|
||||
"""
|
||||
if (
|
||||
self._in_transaction
|
||||
and self._tx_task is not None
|
||||
and asyncio.current_task() is self._tx_task
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Cannot close the NestQuest database from inside its own "
|
||||
"transaction; exit the transaction first"
|
||||
)
|
||||
async with self._get_lock():
|
||||
conn, self._conn = self._conn, None
|
||||
self._rowcount, self._lastrowid = -1, None
|
||||
if conn is None:
|
||||
return
|
||||
|
||||
def _close() -> None:
|
||||
conn.close()
|
||||
|
||||
await self._await_settled(self._executor(_close))
|
||||
|
||||
def _require_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError(
|
||||
"The NestQuest database connection is closed; call open() first"
|
||||
)
|
||||
return self._conn
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Transition-event payload construction for the NestQuest service paths (Feature 10).
|
||||
|
||||
This module is PAYLOAD CONSTRUCTION ONLY: it builds and RETURNS the
|
||||
transition events (event type + payload dict) and never touches the
|
||||
Home Assistant event bus or config. The caller decides how to fire
|
||||
them (the integration's re-export shim wires the returned events to
|
||||
``hass.bus.async_fire``).
|
||||
|
||||
The four documented events (design/ENTITIES-AND-SERVICES.md §3, names
|
||||
in :mod:`~.const`): ``nestquest_quest_completed`` and
|
||||
``nestquest_quest_uncompleted`` fire from the completion service
|
||||
handlers on an actual transition (a no-op re-complete or re-uncomplete
|
||||
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; 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
|
||||
day-complete rule (the pre-extraction implementation fired the
|
||||
completed event on the bus first, then ran the day-complete DB reads;
|
||||
a failure in those reads or the ZoneInfo lookup must not suppress the
|
||||
completed event). :func:`build_quest_completed_event` builds the
|
||||
completed event from the instance and its child/definition only;
|
||||
:func:`build_child_day_complete_event` runs the day-complete DB reads
|
||||
and returns the day-complete event (or ``None``). The shim fires the
|
||||
completed event, then calls the day-complete builder and fires its
|
||||
result. :func:`build_quest_uncompleted_events` builds the single
|
||||
uncompleted event.
|
||||
|
||||
Payload policy: one dict carrying ``child_id``, ``child_name``,
|
||||
``instance_id``, ``quest_title``, the instance's ``window``/
|
||||
``due_date``/``due_time`` so automation authors can filter without a
|
||||
database lookup, and ``occurred_at`` — the event's own strict UTC
|
||||
ISO-8601 timestamp. Completion payloads additionally carry
|
||||
``was_on_time``. No other personal data: names and titles are the
|
||||
household's own records, and nothing beyond them leaks.
|
||||
|
||||
Timezone policy: the day-complete check needs the HA-local "today",
|
||||
which the caller passes in as a plain :class:`datetime.date`. Nothing
|
||||
here reads ``hass.config.time_zone`` — the integration owns HA
|
||||
timezone, the core owns the calendar arithmetic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from .completion import derive_state
|
||||
from .const import (
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
EVENT_QUEST_COMPLETED,
|
||||
EVENT_QUEST_UNCOMPLETED,
|
||||
)
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_instances import CompletionEventsDao, QuestInstancesDao
|
||||
from .dao_rules import QuestDefinitionsDao
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: 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"
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
_UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
|
||||
|
||||
async def _instance_payload(
|
||||
database: NestQuestDatabase, instance_id: int, now_stamp: str
|
||||
) -> dict:
|
||||
"""Build the shared event payload for one quest instance."""
|
||||
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"instance_id: quest instance {instance_id} does not exist"
|
||||
)
|
||||
child = await ChildrenDao(database).get(instance.child_id)
|
||||
definition = await QuestDefinitionsDao(database).get(
|
||||
instance.definition_id
|
||||
)
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
async def build_quest_completed_event(
|
||||
database: NestQuestDatabase,
|
||||
instance_id: int,
|
||||
*,
|
||||
was_on_time: bool | None,
|
||||
) -> tuple[str, dict]:
|
||||
"""Build the ``nestquest_quest_completed`` event for an actual
|
||||
completion.
|
||||
|
||||
Called by the complete_quest service handler AFTER an actual
|
||||
completion transition, with the completion's ``was_on_time`` —
|
||||
computed under the completion lock and carried on the result — so
|
||||
the payload never re-reads mutable latest state a racing reversal
|
||||
could have replaced. A no-op re-complete never reaches here.
|
||||
|
||||
Returns the ``(event_type, payload)`` tuple to fire. This builder
|
||||
touches NO day-complete state: it only reads the instance, its
|
||||
child, and its definition, so a failure in the day-complete
|
||||
evaluation (DB reads, timezone lookup in the shim) cannot suppress
|
||||
the completed event. The caller fires this BEFORE asking
|
||||
:func:`build_child_day_complete_event` for the day-complete event.
|
||||
"""
|
||||
now_stamp = _now_stamp()
|
||||
payload = await _instance_payload(database, instance_id, now_stamp)
|
||||
payload["was_on_time"] = was_on_time
|
||||
return (EVENT_QUEST_COMPLETED, payload)
|
||||
|
||||
|
||||
async def build_child_day_complete_event(
|
||||
database: NestQuestDatabase,
|
||||
completed_payload: dict,
|
||||
*,
|
||||
today: datetime.date,
|
||||
) -> tuple[str, dict] | None:
|
||||
"""Build the ``nestquest_child_day_complete`` event for a
|
||||
completion that cleared the child's whole day, or ``None`` when no
|
||||
day-complete event should fire.
|
||||
|
||||
Called by the complete_quest service handler AFTER the
|
||||
``nestquest_quest_completed`` event has already been fired, with
|
||||
that event's payload (so this builder reuses the same
|
||||
``occurred_at`` stamp and the same ``child_id`` / ``child_name``
|
||||
the completed event carried — one transition, one shared
|
||||
timestamp, matching the pre-extraction implementation).
|
||||
|
||||
Day-complete rule (identical to the pre-extraction implementation
|
||||
and to the all-done binary sensor): quests were owed today and
|
||||
none remain — a zero-quest day can never fire it. Only a
|
||||
completion of TODAY'S instance evaluates it: completing a
|
||||
back-dated or future instance must not announce a cleared day.
|
||||
|
||||
``today`` is the HA-local calendar date the day-complete rule is
|
||||
evaluated against; the integration reads it from
|
||||
``hass.config.time_zone`` and passes it in, so this module never
|
||||
touches HA config.
|
||||
"""
|
||||
# Only a completion of TODAY'S instance evaluates the day-complete
|
||||
# rule: completing a back-dated or future instance must not
|
||||
# announce a cleared day.
|
||||
if completed_payload["due_date"] != today.isoformat():
|
||||
return None
|
||||
instances = await QuestInstancesDao(database).list_by_date_range(
|
||||
completed_payload["child_id"], today.isoformat(), today.isoformat()
|
||||
)
|
||||
completion_events = CompletionEventsDao(database)
|
||||
remaining = 0
|
||||
for instance in instances:
|
||||
latest_for_instance = await completion_events.get_latest_for_instance(
|
||||
instance.id
|
||||
)
|
||||
if derive_state(instance, latest_for_instance, today) != "done":
|
||||
remaining += 1
|
||||
if not instances or remaining:
|
||||
return None
|
||||
return (
|
||||
EVENT_CHILD_DAY_COMPLETE,
|
||||
{
|
||||
"child_id": completed_payload["child_id"],
|
||||
"child_name": completed_payload["child_name"],
|
||||
"quests_due": len(instances),
|
||||
"quests_completed": len(instances),
|
||||
"occurred_at": completed_payload["occurred_at"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def build_quest_uncompleted_events(
|
||||
database: NestQuestDatabase, instance_id: int
|
||||
) -> list[tuple[str, dict]]:
|
||||
"""Build the ``nestquest_quest_uncompleted`` event for an actual reversal.
|
||||
|
||||
Returns a one-element list of ``(event_type, payload)`` to fire.
|
||||
"""
|
||||
payload = await _instance_payload(database, instance_id, _now_stamp())
|
||||
return [(EVENT_QUEST_UNCOMPLETED, payload)]
|
||||
@@ -0,0 +1,377 @@
|
||||
"""The materialization walk (Feature 07 core).
|
||||
|
||||
:func:`materialize` turns the schedule rules + custody presence of the
|
||||
active quest definitions into concrete ``quest_instances`` rows over a
|
||||
date range. It is the pure walk over definitions x dates x assignees x
|
||||
windows; HA scheduling (day-rollover triggers) is a separate later
|
||||
task and lives elsewhere.
|
||||
|
||||
The walk is deterministic and idempotent:
|
||||
|
||||
- All inputs — the active definitions with their rules, assignees and
|
||||
windows, AND the assignee children's presence schedules and scoped
|
||||
overrides — are read inside ONE locked transaction (see
|
||||
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
|
||||
single coherent snapshot rather than a definition state from one
|
||||
instant and a presence state from another.
|
||||
- Presence is snapshotted into an immutable
|
||||
:class:`~.presence.PresenceEngine` from that same coherent read.
|
||||
- For each date in the range, each definition whose decoded rule
|
||||
:func:`~.recurrence.occurs_on` on that date, each assignee who is
|
||||
``is_present`` on that date and each declared window, one instance is
|
||||
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
|
||||
with a ``generated_at`` stamp shared by the whole batch (one coherent
|
||||
time per run).
|
||||
|
||||
A config change that lands AFTER the snapshot is read but BEFORE a
|
||||
tuple's write (an assignment removed, a definition or child deactivated,
|
||||
a window removed) makes that tuple SKIP rather than abort the batch:
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
|
||||
every materialization precondition (active definition, active child,
|
||||
live assignment, declared window, and its CURRENT ``due_time``) inside
|
||||
the SAME transaction as the INSERT, so the check and the write cannot be
|
||||
split by a racing config edit.
|
||||
|
||||
The upsert is idempotent on (definition_id, child_id, due_date,
|
||||
window), so re-running the materialization over the same range never
|
||||
duplicates a row; a tuple whose instance is already completed is SKIPPED
|
||||
(never rewritten, never raised) by
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
|
||||
|
||||
Regeneration: a config or presence change must rebuild future instances
|
||||
so they track the new state. :func:`regenerate_for_definition` covers
|
||||
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
|
||||
covers a single child's presence change (Feature 09). Both delete the
|
||||
affected open instances at or after today — never a completed instance,
|
||||
never the past — and re-run the walk over the rolling horizon
|
||||
``[today, today + horizon_days]`` (``horizon_days`` defaults to
|
||||
``DEFAULT_HORIZON_DAYS``).
|
||||
|
||||
HOOK (Feature 09 presence services): the presence business layer that
|
||||
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
|
||||
presence-state write so a child's future instances track its presence:
|
||||
``set_presence_pattern`` (including clearing/setting an empty pattern),
|
||||
deleting/clearing the presence schedule
|
||||
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||
``create_presence_override``, and ``delete_presence_override``. A
|
||||
schedule delete changes presence state too (the child becomes
|
||||
always-present), so it must regenerate just like the others. This hook
|
||||
is documented, not yet wired: no presence business layer exists yet, and
|
||||
the presence DAO (``dao_presence.py``) must stay free of business rules
|
||||
— never call :func:`regenerate_for_child` from inside the DAO layer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from .const import DEFAULT_HORIZON_DAYS
|
||||
from .dao_instances import QuestInstancesDao, _resolve_today
|
||||
from .dao_rules import (
|
||||
_validate_date,
|
||||
load_materialization_input,
|
||||
schedule_rule_from_storage,
|
||||
schedule_rule_storage_from_record,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
|
||||
from .recurrence import ScheduleRule, occurs_on
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the batch's shared UTC second-precision generation stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_horizon_days(horizon_days: int | None) -> int:
|
||||
"""Validate a regeneration horizon and return the resolved day count.
|
||||
|
||||
``None`` resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. A bool
|
||||
(SQLite would happily bind ``True``/``False`` onto an int), a non-int,
|
||||
or a value below 1 raises ValueError naming the field — the validation
|
||||
runs BEFORE any future-instance delete so a malformed horizon can never
|
||||
delete rows it then fails to rebuild. A negative horizon would
|
||||
otherwise delete first and only then be rejected by
|
||||
:func:`materialize`'s inverted-range check; ``0``/``False`` would
|
||||
silently shrink the window to a single day.
|
||||
"""
|
||||
if horizon_days is None:
|
||||
return DEFAULT_HORIZON_DAYS
|
||||
if (
|
||||
isinstance(horizon_days, bool)
|
||||
or not isinstance(horizon_days, int)
|
||||
or horizon_days < 1
|
||||
):
|
||||
raise ValueError(
|
||||
f"horizon_days must be a positive integer, got {horizon_days!r}"
|
||||
)
|
||||
return horizon_days
|
||||
|
||||
|
||||
def _decode_rule(snapshot) -> ScheduleRule:
|
||||
"""Decode a snapshot's rule record back into a ScheduleRule.
|
||||
|
||||
The record carried by the snapshot is the one the definition
|
||||
referenced at read time, so decoding it here (rather than re-reading
|
||||
through ``ScheduleRulesDao``) preserves the coherence the snapshot
|
||||
guarantees.
|
||||
"""
|
||||
return schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(snapshot.rule)
|
||||
)
|
||||
|
||||
|
||||
def _build_engine(
|
||||
schedules_records,
|
||||
overrides_records,
|
||||
) -> PresenceEngine:
|
||||
"""Convert raw presence records into an immutable PresenceEngine."""
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
for child_id, record in schedules_records.items():
|
||||
schedules[child_id] = PresenceSchedule.decode(
|
||||
child_id, record.anchor_date, record.pattern
|
||||
)
|
||||
overrides: dict[int, list[PresenceOverride]] = {}
|
||||
for child_id, records in overrides_records.items():
|
||||
overrides[child_id] = [
|
||||
PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
record.note,
|
||||
)
|
||||
for record in records
|
||||
]
|
||||
return PresenceEngine(schedules, overrides)
|
||||
|
||||
|
||||
async def materialize(
|
||||
database: NestQuestDatabase,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
*,
|
||||
child_ids: list[int] | None = None,
|
||||
today: datetime.date | None = None,
|
||||
) -> int:
|
||||
"""Generate quest instances for the closed range [start_date, end_date].
|
||||
|
||||
``start_date`` and ``end_date`` must be strict ISO calendar dates
|
||||
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
|
||||
else raises ValueError naming the offending field. Returns the number
|
||||
of instances upserted for the range (idempotent — a re-run refreshes
|
||||
the same rows, never duplicates them).
|
||||
|
||||
``child_ids`` optionally scopes the walk to exactly those children:
|
||||
assignees outside the list are never written, so a caller that only
|
||||
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
|
||||
after a presence change) cannot touch any unrelated child's rows.
|
||||
When omitted (or None) the walk covers every assignee as before.
|
||||
|
||||
``today`` optionally pins the resolved HA-local date the walk's
|
||||
no-past guard compares against; it is threaded into every
|
||||
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
|
||||
omitted the host clock (``datetime.date.today``) is used, which is the
|
||||
historical default. A caller that computed the horizon in Home
|
||||
Assistant's configured time zone MUST pass that same date here so a
|
||||
time zone behind the host around midnight is not rejected as "past".
|
||||
|
||||
``start_date`` is clamped up to the resolved "today" (the same
|
||||
``today`` anchor threaded into the no-past guard), so a caller that
|
||||
passes a ``start_date`` already in the past never yields a
|
||||
past-dated instance: the walk simply begins on today and skips every
|
||||
earlier date. A range entirely in the past therefore produces zero
|
||||
instances rather than an error, and the DAO's no-past guard remains
|
||||
the authoritative backstop under the lock.
|
||||
|
||||
All inputs are read in ONE locked transaction; each tuple's write then
|
||||
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
|
||||
which re-validates the tuple and its current ``due_time`` in the same
|
||||
transaction as the INSERT, so a config change that arrives mid-batch
|
||||
skips the affected tuples rather than aborting the batch or leaving a
|
||||
partial result.
|
||||
"""
|
||||
_validate_date(start_date, "start_date")
|
||||
_validate_date(end_date, "end_date")
|
||||
if end_date < start_date:
|
||||
raise ValueError(
|
||||
f"end_date must be on or after start_date, got "
|
||||
f"{end_date!r} < {start_date!r}"
|
||||
)
|
||||
|
||||
snapshots, schedules_records, overrides_records = await (
|
||||
load_materialization_input(database, start_date, end_date)
|
||||
)
|
||||
engine = _build_engine(schedules_records, overrides_records)
|
||||
child_filter = None if child_ids is None else set(child_ids)
|
||||
|
||||
definitions = [
|
||||
(snapshot.definition.id, _decode_rule(snapshot),
|
||||
snapshot.assignees, snapshot.windows)
|
||||
for snapshot in snapshots
|
||||
]
|
||||
|
||||
generated_at = _now_stamp()
|
||||
instances = QuestInstancesDao(database)
|
||||
|
||||
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
|
||||
# Clamp a past start up to today (the HA-local anchor when the caller
|
||||
# pinned one, else the host clock) so the walk can never emit a
|
||||
# past-dated instance; the DAO's no-past guard remains the backstop.
|
||||
# A range entirely in the past therefore yields zero instances.
|
||||
today_date = _resolve_today(today)
|
||||
start = max(start, today_date)
|
||||
|
||||
count = 0
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
iso = cursor.isoformat()
|
||||
for definition_id, rule, assignees, windows in definitions:
|
||||
if not occurs_on(rule, cursor):
|
||||
continue
|
||||
for child in assignees:
|
||||
if child_filter is not None and child.id not in child_filter:
|
||||
continue
|
||||
if not engine.is_present(child.id, cursor):
|
||||
continue
|
||||
for window in windows:
|
||||
written = await instances.upsert_if_valid(
|
||||
definition_id,
|
||||
child.id,
|
||||
iso,
|
||||
generated_at,
|
||||
window=window.window,
|
||||
due_time=window.due_time,
|
||||
today=today,
|
||||
)
|
||||
if written is not None:
|
||||
count += 1
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return count
|
||||
|
||||
|
||||
async def regenerate_for_definition(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> int:
|
||||
"""Regenerate a definition's future instances after a config change.
|
||||
|
||||
The Feature 06 edit/assignment/activation paths call this after a
|
||||
successful write so future instances track the new state: the
|
||||
definition's open instances at or after today are deleted (never a
|
||||
completed instance, never the past) via
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
|
||||
then the materialization walk re-runs over the rolling horizon
|
||||
``[today, today + horizon_days]`` so the (changed) rule,
|
||||
assignee set and windows re-materialize against today's state.
|
||||
|
||||
"today" is computed the same way the walk computes it —
|
||||
:func:`~.dao_instances._today` — so the delete cutoff and the
|
||||
re-materialization range share one anchor, and the walk's
|
||||
``upsert_if_valid`` no-past guard never rejects the regenerated
|
||||
range. ``today`` optionally pins a caller-resolved HA-local date
|
||||
(threaded into both the delete cutoff and the re-materialization)
|
||||
so a household time zone behind the host around midnight stays
|
||||
consistent. ``horizon_days`` sizes the re-materialization window;
|
||||
when omitted it falls back to :data:`~.const.DEFAULT_HORIZON_DAYS`.
|
||||
A bool, non-int, or sub-1 value raises ValueError BEFORE any delete.
|
||||
Returns the number of instances the re-materialization
|
||||
upserted across the whole snapshot (idempotent — other active
|
||||
definitions' tuples refresh in place, never duplicate).
|
||||
"""
|
||||
horizon = _resolve_horizon_days(horizon_days)
|
||||
today_date = _resolve_today(today)
|
||||
start_date = today_date.isoformat()
|
||||
end_date = (
|
||||
today_date + datetime.timedelta(days=horizon)
|
||||
).isoformat()
|
||||
|
||||
instances = QuestInstancesDao(database)
|
||||
await instances.delete_future_uncompleted(
|
||||
definition_id, start_date, today=today
|
||||
)
|
||||
return await materialize(database, start_date, end_date, today=today)
|
||||
|
||||
|
||||
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
|
||||
# child-presence counterpart to ``regenerate_for_definition``. The
|
||||
# presence business layer that lands in Feature 09 MUST call
|
||||
# ``regenerate_for_child`` after EVERY presence-state write, so future
|
||||
# instances track a child's changed presence: set_presence_pattern
|
||||
# (including clearing/setting an empty pattern), deleting/clearing the
|
||||
# presence schedule (PresenceSchedulesDao.delete),
|
||||
# create_presence_override, and delete_presence_override. A schedule
|
||||
# delete changes presence state too (the child becomes always-present),
|
||||
# so it must regenerate just like the others. It is deliberately NOT
|
||||
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
|
||||
# business rules), and no presence business layer exists yet to host the
|
||||
# call — see the module docstring note above.
|
||||
async def regenerate_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> int:
|
||||
"""Regenerate a child's future instances after a presence change.
|
||||
|
||||
The Feature 09 presence services call this after every
|
||||
presence-state write — set_presence_pattern (including clearing or
|
||||
setting an empty pattern), deleting/clearing the presence schedule
|
||||
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
|
||||
create_presence_override, and delete_presence_override — so a
|
||||
child's future instances track its new presence: the child's open
|
||||
instances at or after today are deleted across ALL its definitions
|
||||
(never a completed instance, never the past) via
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
||||
then the materialization walk re-runs over the rolling horizon
|
||||
``[today, today + horizon_days]`` scoped to that child only,
|
||||
so the child's now-absent/present dates re-materialize against
|
||||
today's presence WITHOUT touching any other child's rows.
|
||||
|
||||
``child_id`` must be a plain int — bool and float are rejected
|
||||
(SQLite binds a bool as 0/1 and a float would round, so a malformed
|
||||
id must never mutate the wrong profile).
|
||||
|
||||
"today" is computed the same way the walk computes it —
|
||||
:func:`~.dao_instances._today` — and the re-materialization range uses the
|
||||
delete's returned effective cutoff (see
|
||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
|
||||
so the delete cutoff and the re-materialization window share ONE
|
||||
execution-day anchor even when the call is queued across midnight.
|
||||
``today`` optionally pins a caller-resolved HA-local date, threaded
|
||||
into the delete cutoff and the re-materialization for the same
|
||||
host-vs-HA time-zone consistency. ``horizon_days`` sizes the
|
||||
re-materialization window; when omitted it falls back to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS`. A bool, non-int, or sub-1 value
|
||||
raises ValueError BEFORE any delete. Returns the number of instances
|
||||
the re-materialization upserted for the child across the whole
|
||||
snapshot.
|
||||
"""
|
||||
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||
|
||||
horizon = _resolve_horizon_days(horizon_days)
|
||||
today_date = _resolve_today(today)
|
||||
|
||||
instances = QuestInstancesDao(database)
|
||||
# Use the delete's returned effective cutoff as the materialize anchor,
|
||||
# NOT the independently-computed "today" above. If the call is queued
|
||||
# across midnight the delete clamps its cutoff to the execution-day;
|
||||
# materializing from the stale "today" (now yesterday) would trip the
|
||||
# walk's no-past guard and leave the child's future instances deleted
|
||||
# but not rebuilt. One anchor for both keeps them consistent.
|
||||
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
|
||||
child_id, today_date.isoformat(), today=today
|
||||
)
|
||||
start = datetime.date.fromisoformat(effective_cutoff)
|
||||
end_date = (start + datetime.timedelta(days=horizon)).isoformat()
|
||||
return await materialize(
|
||||
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
||||
)
|
||||
@@ -0,0 +1,593 @@
|
||||
"""Versioned schema migration runner for the NestQuest SQLite database.
|
||||
|
||||
The runner owns one metadata table, ``nestquest_schema_version``, holding
|
||||
a single row with the schema version the database file currently
|
||||
reflects. Migrations are declared as an ordered list: entry ``n``
|
||||
upgrades version ``n-1`` to version ``n``. Applying a migration means
|
||||
running its DDL inside one transaction and stamping the new version in
|
||||
that same transaction, so a database never persists a half-applied
|
||||
migration: any failure rolls the statements and the version stamp back
|
||||
together, leaving the file at its prior version.
|
||||
|
||||
Version 0 means "the file exists but carries no NestQuest schema yet"
|
||||
— a fresh file, or a file this integration has never written. Starting
|
||||
the runner against version 0 applies migration 1, which applies the
|
||||
full v1 DDL from :mod:`.schema` and stamps version 1.
|
||||
|
||||
Migration 1 was rewritten pre-release (D-007): nothing was ever
|
||||
shipped, so the v1 DDL now creates the ``quest_*`` tables directly
|
||||
instead of the legacy ``task_*`` spellings, and no compatibility shim
|
||||
exists. Dev machines that already hold a version-1 database with
|
||||
``task_*`` tables are handled by migration 2, which detects the legacy
|
||||
table names and ``ALTER TABLE ... RENAME TO``s them to their current
|
||||
names before anything else assumes the new model. Migration 3 brings
|
||||
pre-D-008 databases to the multi-assignee model: it creates
|
||||
``quest_definition_assignees`` and, when ``quest_definitions`` still
|
||||
carries a single ``child_id`` column, rebuilds the table without it,
|
||||
copying each definition's assignee across. Migration 4 adds the
|
||||
windows table, migration 5 rebuilds ``quest_instances`` onto the
|
||||
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). 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.
|
||||
|
||||
Migration entries are either an ordered sequence of SQL statements or
|
||||
an async callable taking ``(database, target_version)``. Callables
|
||||
exist for steps whose SQL depends on the database's current state (a
|
||||
legacy rename only fires when a legacy table is present; a rebuild only
|
||||
when the old column shape is present) and own their transaction so they
|
||||
can bracket it with connection pragmas the runner's uniform transaction
|
||||
cannot express.
|
||||
|
||||
Each migration's statements and its version stamp run inside ONE
|
||||
transaction, and the stamp is written as DELETE + INSERT rather than
|
||||
UPDATE because a version-0 database has no version row to update yet.
|
||||
An UPDATE form would silently match zero rows there and leave the
|
||||
database migrated but unstamped — reporting version 0 forever, and
|
||||
re-applying migration 1 on every startup. The DELETE+INSERT shape is
|
||||
also what makes the stamp a replace, so the row never duplicates.
|
||||
|
||||
Idempotence across restarts: a database already at the latest version
|
||||
has no pending migrations, so the runner opens a read-only check, finds
|
||||
nothing to do, and leaves the file byte-untouched (no write transaction
|
||||
is opened, so no WAL churn).
|
||||
|
||||
Migration list policy: entries are append-only. Once shipped, a
|
||||
migration's SQL must never be edited — an old database applying it for
|
||||
the first time and a fresh database would then diverge. Fix-forward
|
||||
with a new migration instead. The version stamp lives in the same
|
||||
SQLite file as the data it describes, so restoring a backup restores
|
||||
its matching schema version with it.
|
||||
|
||||
Corrupt or missing files are not this module's concern: startup
|
||||
handling of those cases is task d5bca74b, which decides whether to
|
||||
open fresh or raise ConfigEntryNotReady. This runner assumes it is
|
||||
handed an open, working connection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Union
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
from .schema import (
|
||||
COMPLETION_EVENTS_TABLE_SQL,
|
||||
QUEST_DEFINITIONS_TABLE_SQL,
|
||||
QUEST_INSTANCES_TABLE_SQL,
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
SCHEMA_V7_META_STATE_DDL,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: One migration entry: either an ordered sequence of SQL statements or
|
||||
#: an async callable taking ``(database, target_version)`` that manages
|
||||
#: its own transaction and stamps via :func:`stamp_schema_version`.
|
||||
MigrationStep = Union[
|
||||
Sequence[str],
|
||||
Callable[[NestQuestDatabase, int], Awaitable[None]],
|
||||
]
|
||||
|
||||
#: Metadata table holding the applied schema version. Prefixed with the
|
||||
#: integration domain so a config directory shared with other tools can
|
||||
#: never collide with a foreign ``schema_version`` table.
|
||||
VERSION_TABLE = "nestquest_schema_version"
|
||||
|
||||
#: DDL for the version metadata table. The singleton-row invariant is
|
||||
#: enforced by the schema itself: ``id`` is fixed at 1 by CHECK, keyed
|
||||
#: PRIMARY KEY, so the table can never hold a second row, and the
|
||||
#: stamp write is an UPDATE (no DELETE+INSERT needed). ``version`` is
|
||||
#: a positive integer: version 0 means "no row" and is never stored,
|
||||
#: so a negative or zero stored value is corruption, not a state the
|
||||
#: runner can act on.
|
||||
VERSION_TABLE_DDL = f"""
|
||||
CREATE TABLE IF NOT EXISTS {VERSION_TABLE} (
|
||||
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
|
||||
version INTEGER NOT NULL CHECK (version >= 1)
|
||||
)
|
||||
"""
|
||||
|
||||
#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades
|
||||
#: version ``n-1`` to version ``n``.
|
||||
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
|
||||
|
||||
#: The assignees table DDL, shared with migration 1 via the schema
|
||||
#: module so a rebuilt and a freshly created table cannot drift.
|
||||
_ASSIGNEES_TABLE_DDL: str = SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL[0]
|
||||
|
||||
#: Legacy (pre-D-007) table name -> current table name. Order matters:
|
||||
#: the parent table renames first so the child's foreign key reference
|
||||
#: is rewritten while its parent's new name is already in place.
|
||||
LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = (
|
||||
("task_definitions", "quest_definitions"),
|
||||
("task_instances", "quest_instances"),
|
||||
)
|
||||
|
||||
|
||||
async def stamp_schema_version(
|
||||
database: NestQuestDatabase, version: int
|
||||
) -> None:
|
||||
"""Write the schema version stamp (inside the caller's transaction).
|
||||
|
||||
INSERT keyed on the singleton row (id = 1) with an upsert: a fresh
|
||||
file has no row yet, so UPDATE alone would silently match zero rows
|
||||
there and leave a migrated-but-unstamped database.
|
||||
"""
|
||||
await database.execute(
|
||||
f"INSERT INTO {VERSION_TABLE} (id, version) "
|
||||
"VALUES (1, ?) "
|
||||
"ON CONFLICT (id) DO UPDATE SET version = excluded.version",
|
||||
(version,),
|
||||
)
|
||||
|
||||
|
||||
async def _table_has_column(
|
||||
database: NestQuestDatabase, table: str, column: str
|
||||
) -> bool:
|
||||
"""Return True when ``table`` currently carries ``column``."""
|
||||
rows = await database.fetch_all(f"PRAGMA table_info({table})")
|
||||
return any(row[1] == column for row in rows)
|
||||
|
||||
|
||||
#: Window assigned to instances migrated from the pre-window instance
|
||||
#: shape (D-008). Pre-window rows were generated for whole-day
|
||||
#: obligations; the morning window is the documented default landing
|
||||
#: spot, and future dates are regenerated with real window declarations
|
||||
#: by materialization (delete_future_uncompleted + re-run).
|
||||
LEGACY_MIGRATED_WINDOW = "morning"
|
||||
|
||||
|
||||
async def _rename_legacy_task_tables(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Rename any surviving ``task_*`` tables to their ``quest_*`` names.
|
||||
|
||||
Dev-machines-only path (D-007): a database stamped version 1 by the
|
||||
pre-rename runner holds ``task_definitions``/``task_instances``.
|
||||
Each legacy name still present is renamed; fresh databases find
|
||||
nothing and this step is a no-op. Modern SQLite rewrites foreign
|
||||
key clauses in other tables to follow the rename, so the renamed
|
||||
schema is indistinguishable from a freshly created one apart from
|
||||
internal autoindex names. Runs in its own transaction together
|
||||
with the version stamp, so a crash rolls both back.
|
||||
"""
|
||||
async with database.transaction():
|
||||
for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES:
|
||||
row = await database.fetch_one(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name = ?",
|
||||
(legacy_name,),
|
||||
)
|
||||
if row is not None:
|
||||
await database.execute(
|
||||
f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"'
|
||||
)
|
||||
LOGGER.info(
|
||||
"Renamed legacy table %s to %s",
|
||||
legacy_name,
|
||||
current_name,
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
|
||||
|
||||
async def _add_definition_assignees(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Bring definitions to the multi-assignee model (D-008).
|
||||
|
||||
Two shapes arrive here:
|
||||
|
||||
- Fresh or renamed databases whose ``quest_definitions`` already
|
||||
matches the current canonical DDL (no ``child_id`` column): only
|
||||
the assignees table needs creating (``IF NOT EXISTS`` is a no-op
|
||||
when migration 1 already made it).
|
||||
- Databases stamped at version 2 whose ``quest_definitions`` still
|
||||
carries the single ``child_id`` column: the table is rebuilt via
|
||||
SQLite's canonical procedure — create the new table, copy rows,
|
||||
move each definition's assignee into
|
||||
``quest_definition_assignees``, drop the old table, rename — so
|
||||
``DROP COLUMN``'s foreign-key restriction never applies and no
|
||||
definition or instance row is lost.
|
||||
|
||||
The copy runs with ``foreign_keys = OFF`` because dropping the old
|
||||
table under enforcement would fire the implicit DELETE and violate
|
||||
the ``quest_instances`` foreign key; ``PRAGMA foreign_key_check``
|
||||
runs inside the same transaction before the stamp, so a corrupt
|
||||
copy aborts the whole migration instead of persisting. The pragma
|
||||
is a connection-level no-op inside a transaction, hence the
|
||||
off/transaction/on bracket.
|
||||
"""
|
||||
legacy_child_id = await _table_has_column(
|
||||
database, "quest_definitions", "child_id"
|
||||
)
|
||||
await database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
async with database.transaction():
|
||||
await database.execute(_ASSIGNEES_TABLE_DDL)
|
||||
if legacy_child_id:
|
||||
await database.execute(
|
||||
"CREATE TABLE quest_definitions_rebuilt "
|
||||
f"{QUEST_DEFINITIONS_TABLE_SQL}"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT INTO quest_definitions_rebuilt "
|
||||
"(id, title, description, icon, schedule_rule_id, "
|
||||
"due_time, is_active, created_at) "
|
||||
"SELECT id, title, description, icon, "
|
||||
"schedule_rule_id, due_time, is_active, created_at "
|
||||
"FROM quest_definitions ORDER BY id"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT OR IGNORE INTO quest_definition_assignees "
|
||||
"(definition_id, child_id) "
|
||||
"SELECT id, child_id FROM quest_definitions"
|
||||
)
|
||||
await database.execute("DROP TABLE quest_definitions")
|
||||
await database.execute(
|
||||
"ALTER TABLE quest_definitions_rebuilt "
|
||||
"RENAME TO quest_definitions"
|
||||
)
|
||||
violations = await database.fetch_all(
|
||||
"PRAGMA foreign_key_check"
|
||||
)
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
"definition rebuild produced foreign-key "
|
||||
f"violations: {violations!r}"
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
finally:
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _widen_instance_key(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Bring ``quest_instances`` to the D-008 instance key.
|
||||
|
||||
Databases whose instance table still carries the pre-window shape
|
||||
(no ``window`` column, UNIQUE on (definition_id, due_date)) are
|
||||
rebuilt via SQLite's canonical procedure into the widened shape —
|
||||
UNIQUE (definition_id, child_id, due_date, window) — with every
|
||||
migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing
|
||||
ids (and the completion events referencing them) survive intact.
|
||||
The old rows were unique per (definition, due_date), so pinning one
|
||||
window cannot collide with the widened key. Fresh databases whose
|
||||
table already matches the canonical shape only stamp.
|
||||
|
||||
The rebuild runs with ``foreign_keys = OFF`` (dropping the old
|
||||
table under enforcement would fire the implicit DELETE and violate
|
||||
the ``completion_events`` foreign key) and verifies
|
||||
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||
stamp, so a corrupt copy aborts the whole migration.
|
||||
"""
|
||||
legacy_shape = not await _table_has_column(
|
||||
database, "quest_instances", "window"
|
||||
)
|
||||
await database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
async with database.transaction():
|
||||
if legacy_shape:
|
||||
await database.execute(
|
||||
"CREATE TABLE quest_instances_rebuilt "
|
||||
f"{QUEST_INSTANCES_TABLE_SQL}"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT INTO quest_instances_rebuilt "
|
||||
"(id, definition_id, child_id, window, due_date, "
|
||||
"due_time, generated_at) "
|
||||
"SELECT id, definition_id, child_id, ?, due_date, "
|
||||
"due_time, generated_at "
|
||||
"FROM quest_instances ORDER BY id",
|
||||
(LEGACY_MIGRATED_WINDOW,),
|
||||
)
|
||||
await database.execute("DROP TABLE quest_instances")
|
||||
await database.execute(
|
||||
"ALTER TABLE quest_instances_rebuilt "
|
||||
"RENAME TO quest_instances"
|
||||
)
|
||||
violations = await database.fetch_all(
|
||||
"PRAGMA foreign_key_check"
|
||||
)
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
"instance rebuild produced foreign-key "
|
||||
f"violations: {violations!r}"
|
||||
)
|
||||
LOGGER.info(
|
||||
"Widened quest_instances key; migrated rows pinned "
|
||||
"to the %r window",
|
||||
LEGACY_MIGRATED_WINDOW,
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
finally:
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _add_actor_child_id(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Bring ``completion_events`` to the D-008 actor-child shape.
|
||||
|
||||
The ``actor_child_id`` column records which panel profile was
|
||||
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
|
||||
because the canonical shape also carries an actor-pair coherence
|
||||
CHECK (panel events require it, user events forbid it), and CHECK
|
||||
constraints cannot be added to an existing table. Databases with
|
||||
the legacy shape are therefore rebuilt via SQLite's canonical
|
||||
procedure, backfilling each legacy panel row's ``actor_child_id``
|
||||
to ``child_id`` — pre-D-008 panel completions were always recorded
|
||||
against the tapped child themselves — while admin rows stay NULL.
|
||||
Row ids are preserved so ``quest_instances`` references and the
|
||||
append-only history stay intact. Fresh databases already match
|
||||
the canonical shape and only stamp.
|
||||
|
||||
The rebuild runs with ``foreign_keys = OFF`` and verifies
|
||||
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
|
||||
completion_events table keeps its append-only guarantee.
|
||||
"""
|
||||
legacy_shape = not await _table_has_column(
|
||||
database, "completion_events", "actor_child_id"
|
||||
)
|
||||
await database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
async with database.transaction():
|
||||
if legacy_shape:
|
||||
await database.execute(
|
||||
"CREATE TABLE completion_events_rebuilt "
|
||||
f"{COMPLETION_EVENTS_TABLE_SQL}"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT INTO completion_events_rebuilt "
|
||||
"(id, instance_id, child_id, event_type, actor_source, "
|
||||
"actor_user_id, actor_child_id, occurred_at, "
|
||||
"was_on_time) "
|
||||
"SELECT id, instance_id, child_id, event_type, "
|
||||
"actor_source, actor_user_id, "
|
||||
"CASE WHEN actor_source = 'panel' THEN child_id "
|
||||
"ELSE NULL END, "
|
||||
"occurred_at, was_on_time "
|
||||
"FROM completion_events ORDER BY id"
|
||||
)
|
||||
await database.execute("DROP TABLE completion_events")
|
||||
await database.execute(
|
||||
"ALTER TABLE completion_events_rebuilt "
|
||||
"RENAME TO completion_events"
|
||||
)
|
||||
violations = await database.fetch_all(
|
||||
"PRAGMA foreign_key_check"
|
||||
)
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
"completion_events rebuild produced foreign-key "
|
||||
f"violations: {violations!r}"
|
||||
)
|
||||
LOGGER.info(
|
||||
"Added actor_child_id to completion_events; legacy "
|
||||
"panel rows backfilled to their own child_id"
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
finally:
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
#: The ordered migration list. Append-only: never edit an applied
|
||||
#: entry, add the next one instead. (Migration 1's content was
|
||||
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
||||
MIGRATIONS: Sequence[MigrationStep] = [
|
||||
MIGRATION_1_V1_DDL,
|
||||
_rename_legacy_task_tables,
|
||||
_add_definition_assignees,
|
||||
# Migration 4 (D-008): the windows table is a plain new table — a
|
||||
# CREATE IF NOT EXISTS suffices for both fresh databases (which
|
||||
# already made it in migration 1) and pre-window databases.
|
||||
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
|
||||
# Migration 5 (D-008): rebuild quest_instances onto the widened
|
||||
# (definition_id, child_id, due_date, window) key.
|
||||
_widen_instance_key,
|
||||
# Migration 6 (D-008): rebuild completion_events with the
|
||||
# actor_child_id column and its actor-pair coherence CHECK,
|
||||
# backfilling legacy panel rows to their own child_id.
|
||||
_add_actor_child_id,
|
||||
# Migration 7 (Feature 11): the generic meta_state key/value table
|
||||
# — a plain new table, so CREATE IF NOT EXISTS suffices for both
|
||||
# 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),
|
||||
]
|
||||
|
||||
|
||||
async def _create_version_table(database: NestQuestDatabase) -> None:
|
||||
"""Create the version metadata table if it does not exist.
|
||||
|
||||
The table is created before any version check: it is the runner's
|
||||
own bookkeeping, and creating it is idempotent. The DDL enforces
|
||||
the singleton-row invariant (``id`` fixed at 1) and a positive
|
||||
integer version at the database level.
|
||||
"""
|
||||
await database.execute(VERSION_TABLE_DDL)
|
||||
|
||||
|
||||
async def read_schema_version(database: NestQuestDatabase) -> int:
|
||||
"""Return the schema version recorded in the database.
|
||||
|
||||
Returns 0 when the version table or its row is missing — the
|
||||
"fresh file" case. A stored version higher than the runner knows
|
||||
is surfaced as-is; the caller decides how to treat a future version.
|
||||
|
||||
Reading is deliberately passive: a missing table raises, it is not
|
||||
swallowed into 0, because :func:`apply_migrations` always creates
|
||||
the table before reading and a direct reader hitting "no such
|
||||
table" is a caller bug worth surfacing, not a fresh file.
|
||||
"""
|
||||
row = await database.fetch_one(
|
||||
f"SELECT version FROM {VERSION_TABLE} LIMIT 1"
|
||||
)
|
||||
if row is None:
|
||||
return 0
|
||||
version = row[0]
|
||||
if not isinstance(version, int) or isinstance(version, bool):
|
||||
raise RuntimeError(
|
||||
f"Schema version metadata is not an integer: {version!r}"
|
||||
)
|
||||
if version < 1:
|
||||
raise RuntimeError(
|
||||
f"Schema version metadata is invalid (must be >= 1): {version!r}"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
#: One asyncio.Lock per connection wrapper, created lazily and bound to
|
||||
#: the running loop, serializing whole apply_migrations runs so two
|
||||
#: concurrent callers on one connection queue instead of colliding on
|
||||
#: the wrapper's single-transaction rule.
|
||||
_MIGRATION_LOCKS: dict[int, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
|
||||
"""Return the migration lock bound to ``database``'s running loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _MIGRATION_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_MIGRATION_LOCKS[key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
async def apply_migrations(
|
||||
database: NestQuestDatabase,
|
||||
migrations: Sequence[MigrationStep] = MIGRATIONS,
|
||||
) -> int:
|
||||
"""Bring the database schema up to the latest version.
|
||||
|
||||
Reads the current version, applies every pending migration in order
|
||||
— each inside one transaction that also stamps the new version —
|
||||
and returns the resulting version. Applying zero pending
|
||||
migrations (database already current) is a no-op that logs at debug
|
||||
level and opens no write transaction.
|
||||
|
||||
The version is re-read inside each migration transaction before
|
||||
deciding to apply it: the outer read is advisory only (loop bounds,
|
||||
no-op fast path), and on one shared connection two interleaved
|
||||
runners would otherwise both read the same version and both apply
|
||||
a non-idempotent migration. Under the transaction's lock the
|
||||
second runner sees the first runner's stamp and skips.
|
||||
|
||||
Runners on one connection are serialized by an asyncio lock held
|
||||
for the whole decide-and-apply span: NestQuestDatabase rejects a
|
||||
second transaction while one is active (RuntimeError), so
|
||||
concurrent callers must queue, and the lock makes that queueing
|
||||
explicit. The loser of the race re-reads the version once it
|
||||
acquires the lock, sees the winner's stamp, and applies nothing.
|
||||
|
||||
Raises whatever the failing migration raised, with the database
|
||||
rolled back to its prior version and prior rows untouched.
|
||||
"""
|
||||
async with _migration_lock(database):
|
||||
await _create_version_table(database)
|
||||
current = await read_schema_version(database)
|
||||
latest = len(migrations)
|
||||
|
||||
if current > latest:
|
||||
raise RuntimeError(
|
||||
f"Database schema version {current} is newer than this "
|
||||
f"integration understands (latest known: {latest}); "
|
||||
"downgrades are not supported"
|
||||
)
|
||||
|
||||
if current == latest:
|
||||
LOGGER.debug(
|
||||
"NestQuest schema already at version %d; "
|
||||
"no migrations to apply",
|
||||
current,
|
||||
)
|
||||
return current
|
||||
|
||||
for target in range(current + 1, latest + 1):
|
||||
applied = False
|
||||
step = migrations[target - 1]
|
||||
if callable(step):
|
||||
# Callable migrations own their transaction: some steps
|
||||
# (table rebuilds) must run connection pragmas such as
|
||||
# ``foreign_keys = OFF`` BEFORE opening one, which the
|
||||
# runner's uniform transaction cannot express, and they
|
||||
# stamp inside their own transaction so a failure rolls
|
||||
# the statements and the version stamp back together.
|
||||
# The version is re-read here under the migration lock:
|
||||
# runners on one connection are serialized by it, so
|
||||
# the version cannot change between this read and the
|
||||
# callable's commit.
|
||||
in_tx_version = await read_schema_version(database)
|
||||
if in_tx_version > latest:
|
||||
raise RuntimeError(
|
||||
f"Database schema version {in_tx_version} is newer "
|
||||
f"than this integration understands (latest known: "
|
||||
f"{latest}); downgrades are not supported"
|
||||
)
|
||||
if in_tx_version < target:
|
||||
await step(database, target)
|
||||
applied = True
|
||||
else:
|
||||
async with database.transaction():
|
||||
# Re-read the version INSIDE the write transaction:
|
||||
# the outer read was advisory, and the wrapper
|
||||
# serializes individual statements, not the
|
||||
# read-decide-apply decision. Under the
|
||||
# transaction's lock the version cannot change
|
||||
# between the read and the stamp, and the loser of
|
||||
# an interleaved race (a queued second caller
|
||||
# re-running apply_migrations after the winner
|
||||
# already migrated) re-reads here, sees the
|
||||
# winner's stamp, and skips instead of re-applying
|
||||
# a non-idempotent migration with a stale version.
|
||||
in_tx_version = await read_schema_version(database)
|
||||
if in_tx_version > latest:
|
||||
raise RuntimeError(
|
||||
f"Database schema version {in_tx_version} is "
|
||||
f"newer than this integration understands "
|
||||
f"(latest known: {latest}); downgrades are "
|
||||
f"not supported"
|
||||
)
|
||||
if in_tx_version < target:
|
||||
for sql in step:
|
||||
await database.execute(sql)
|
||||
await stamp_schema_version(database, target)
|
||||
applied = True
|
||||
applied_statements = len(step)
|
||||
if applied:
|
||||
LOGGER.info(
|
||||
"NestQuest schema migrated to version %d",
|
||||
target,
|
||||
)
|
||||
|
||||
return latest
|
||||
@@ -0,0 +1,477 @@
|
||||
"""The presence and custody model (Feature 05).
|
||||
|
||||
PURE module: no Home Assistant imports, no database access, no
|
||||
timezone logic — it operates on plain calendar dates only (feature
|
||||
guardrail). The later engine (cycle position, ``is_present``,
|
||||
overrides, previews) builds directly on this model.
|
||||
|
||||
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
|
||||
week is this child at this house?" for an N-week repeating custody
|
||||
pattern. Two of the household's three children follow a two-week
|
||||
rotation; a child with NO schedule row is present every day (that
|
||||
default lives in the engine, not here).
|
||||
|
||||
Pattern encoding: the model's canonical serialization is the SAME
|
||||
pipe-separated CSV the database stores (see schema.py's
|
||||
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
|
||||
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
|
||||
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
|
||||
empty segment means absent every day of that week. Encoding sorts
|
||||
each segment's weekdays and uses the exact segment order, so
|
||||
``decode(encode(schedule))`` round-trips losslessly and two schedules
|
||||
with equal patterns produce equal strings.
|
||||
|
||||
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
|
||||
computed from anchor-date arithmetic ONLY — never ISO week numbers or
|
||||
week parity (D-004): 53-week years silently invert parity on January
|
||||
1st. The arithmetic lives in the engine task; this module validates
|
||||
that the anchor is a strict calendar date.
|
||||
|
||||
Validation policy: construction is total — every reject happens in
|
||||
``__init__`` so no invalid schedule can ever exist:
|
||||
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
|
||||
the database CHECK (schema.py) and keeps anchor arithmetic and the
|
||||
encoded pattern human-readable; revisit both together if a longer
|
||||
cycle is ever needed.
|
||||
- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly
|
||||
(no gaps, no extra weeks) with sets of integers 0..6 (bools
|
||||
rejected: True would silently mean Monday).
|
||||
- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a
|
||||
``datetime.date``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
|
||||
#: The schema's documented cap for cycle_length_weeks (schema.py keeps
|
||||
#: the pattern human-readable and the anchor arithmetic small).
|
||||
MAX_CYCLE_LENGTH_WEEKS = 4
|
||||
|
||||
_DATE_FORMAT = "%Y-%m-%d"
|
||||
|
||||
|
||||
def _parse_date(value: object, field_name: str) -> datetime.date:
|
||||
"""Accept a datetime.date or a strict YYYY-MM-DD string.
|
||||
|
||||
``datetime.datetime`` is rejected explicitly: it SUBCLASSES
|
||||
datetime.date, so a bare isinstance check would store a value with
|
||||
a time component and every later calendar-date comparison (e.g.
|
||||
``PresenceOverride.covers``) would raise TypeError.
|
||||
"""
|
||||
if isinstance(value, datetime.datetime):
|
||||
raise ValueError(
|
||||
f"{field_name} must be a plain calendar date, not a "
|
||||
f"datetime with a time component: {value!r}"
|
||||
)
|
||||
if isinstance(value, datetime.date):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"{field_name} must be a datetime.date or an ISO date string, "
|
||||
f"got {value!r}"
|
||||
)
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() != value:
|
||||
raise ValueError(
|
||||
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _validate_week_index(week: object, cycle_length_weeks: int) -> int:
|
||||
"""Reject non-int week indices (bools included) and out-of-cycle."""
|
||||
if isinstance(week, bool) or not isinstance(week, int):
|
||||
raise ValueError(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
)
|
||||
if not 0 <= week < cycle_length_weeks:
|
||||
raise ValueError(
|
||||
f"pattern week index must be in 0..{cycle_length_weeks - 1}, "
|
||||
f"got {week}"
|
||||
)
|
||||
return week
|
||||
|
||||
|
||||
def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
|
||||
"""Validate one week's present-weekday set."""
|
||||
if isinstance(weekdays, (str, bytes)) or weekdays is None:
|
||||
raise ValueError(
|
||||
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
|
||||
f"got {weekdays!r}"
|
||||
)
|
||||
try:
|
||||
members = list(weekdays)
|
||||
except TypeError:
|
||||
raise ValueError(
|
||||
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
|
||||
f"got {weekdays!r}"
|
||||
) from None
|
||||
for member in members:
|
||||
if isinstance(member, bool) or not isinstance(member, int):
|
||||
raise ValueError(
|
||||
f"pattern[{week}] weekday must be an int 0..6, got {member!r}"
|
||||
)
|
||||
if not 0 <= member <= 6:
|
||||
raise ValueError(
|
||||
f"pattern[{week}] weekday must be in 0..6 (Monday=0), "
|
||||
f"got {member}"
|
||||
)
|
||||
return frozenset(members)
|
||||
|
||||
|
||||
def _validate_cycle_length(value: object) -> int:
|
||||
"""Reject non-int and out-of-range cycle lengths (bools included)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"cycle_length_weeks must be an integer, got {value!r}"
|
||||
)
|
||||
if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS:
|
||||
raise ValueError(
|
||||
f"cycle_length_weeks must be between 1 and "
|
||||
f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools would silently address child 1)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class PresenceSchedule:
|
||||
"""One child's N-week repeating presence pattern.
|
||||
|
||||
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
|
||||
to the set of present weekdays (ints, Monday=0). A week with an
|
||||
empty set means the child is absent every day of that week — which
|
||||
is distinct from having no schedule at all (the engine treats a
|
||||
missing schedule as present every day).
|
||||
"""
|
||||
|
||||
child_id: int
|
||||
cycle_length_weeks: int
|
||||
anchor_date: datetime.date
|
||||
pattern: MappingProxyType # week index -> frozenset of weekdays
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
child_id: int,
|
||||
cycle_length_weeks: int,
|
||||
anchor_date: object,
|
||||
pattern: dict[int, object],
|
||||
) -> None:
|
||||
object.__setattr__(self, "child_id", _validate_child_id(child_id))
|
||||
cycle = _validate_cycle_length(cycle_length_weeks)
|
||||
object.__setattr__(self, "cycle_length_weeks", cycle)
|
||||
object.__setattr__(
|
||||
self, "anchor_date", _parse_date(anchor_date, "anchor_date")
|
||||
)
|
||||
if not isinstance(pattern, dict):
|
||||
raise ValueError(
|
||||
"pattern must map week index to a set of present "
|
||||
f"weekdays, got {pattern!r}"
|
||||
)
|
||||
normalised: dict[int, frozenset[int]] = {}
|
||||
for week, weekdays in pattern.items():
|
||||
index = _validate_week_index(week, cycle)
|
||||
normalised[index] = _validate_weekday_set(index, weekdays)
|
||||
missing = [
|
||||
week for week in range(cycle) if week not in normalised
|
||||
]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"pattern must cover week indices 0..{cycle - 1}; "
|
||||
f"missing {missing}"
|
||||
)
|
||||
# Frozen must mean frozen: a bare dict would let callers inject
|
||||
# unvalidated weekdays (or drop required weeks) AFTER
|
||||
# construction, bypassing total validation. The mapping
|
||||
# proxy exposes the validated contents read-only; the
|
||||
# per-week sets are already frozensets.
|
||||
object.__setattr__(self, "pattern", MappingProxyType(normalised))
|
||||
|
||||
def encode(self) -> str:
|
||||
"""Serialize to the database's pipe-separated CSV shape.
|
||||
|
||||
Segments appear in week order; each segment is the week's
|
||||
present weekdays as a sorted comma-separated list (an empty set
|
||||
encodes as an empty segment). Two schedules with equal
|
||||
patterns encode identically.
|
||||
"""
|
||||
segments = [
|
||||
",".join(str(day) for day in sorted(self.pattern[week]))
|
||||
for week in range(self.cycle_length_weeks)
|
||||
]
|
||||
return "|".join(segments)
|
||||
|
||||
@classmethod
|
||||
def decode(
|
||||
cls,
|
||||
child_id: int,
|
||||
anchor_date: object,
|
||||
encoded: str,
|
||||
) -> "PresenceSchedule":
|
||||
"""Build a schedule from its encoded pattern.
|
||||
|
||||
The cycle length is derived from the segment count, mirroring
|
||||
the database's segment-count CHECK. Decoding is strict: wrong
|
||||
shapes, non-numeric or out-of-range weekdays are rejected —
|
||||
the database CHECKs make such rows unrepresentable, and this
|
||||
is the same contract on the model side. A segment count above
|
||||
the documented cycle cap is rejected by the ordinary
|
||||
construction validation.
|
||||
"""
|
||||
if not isinstance(encoded, str):
|
||||
raise ValueError(
|
||||
f"encoded pattern must be a string, got {encoded!r}"
|
||||
)
|
||||
segments = encoded.split("|")
|
||||
pattern: dict[int, set[int]] = {}
|
||||
for week, segment in enumerate(segments):
|
||||
weekdays: set[int] = set()
|
||||
if segment:
|
||||
for part in segment.split(","):
|
||||
if len(part) != 1 or part not in "0123456":
|
||||
raise ValueError(
|
||||
f"encoded segment {week} carries an invalid "
|
||||
f"weekday {part!r} (single digits 0-6 only)"
|
||||
)
|
||||
weekdays.add(int(part))
|
||||
pattern[week] = weekdays
|
||||
return cls(child_id, len(segments), anchor_date, pattern)
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class PresenceOverride:
|
||||
"""A date-specific present/absent flag beating any pattern.
|
||||
|
||||
Covers a single date (``end_date == start_date``) or an inclusive
|
||||
range. A concrete range is the storage shape
|
||||
(``presence_overrides.end_date`` is NOT NULL), so the model
|
||||
requires both bounds and validates start <= end. Construction and
|
||||
validation live here so the engine can trust every instance.
|
||||
"""
|
||||
|
||||
child_id: int
|
||||
start_date: datetime.date
|
||||
end_date: datetime.date
|
||||
is_present: bool
|
||||
note: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
child_id: int,
|
||||
start_date: object,
|
||||
end_date: object,
|
||||
is_present: bool,
|
||||
note: str | None = None,
|
||||
) -> None:
|
||||
object.__setattr__(self, "child_id", _validate_child_id(child_id))
|
||||
start = _parse_date(start_date, "start_date")
|
||||
end = _parse_date(end_date, "end_date")
|
||||
if end < start:
|
||||
raise ValueError(
|
||||
f"end_date must be on or after start_date, got "
|
||||
f"{end.isoformat()} < {start.isoformat()}"
|
||||
)
|
||||
object.__setattr__(self, "start_date", start)
|
||||
object.__setattr__(self, "end_date", end)
|
||||
if not isinstance(is_present, bool):
|
||||
raise ValueError(
|
||||
f"is_present must be a real bool, got {is_present!r}"
|
||||
)
|
||||
object.__setattr__(self, "is_present", is_present)
|
||||
if note is not None and not isinstance(note, str):
|
||||
raise ValueError(f"note must be a string or None, got {note!r}")
|
||||
object.__setattr__(self, "note", note)
|
||||
|
||||
def covers(self, date: datetime.date) -> bool:
|
||||
"""Return True when the override applies to ``date``."""
|
||||
return self.start_date <= date <= self.end_date
|
||||
|
||||
|
||||
def cycle_week_index(
|
||||
schedule: PresenceSchedule, target_date: object
|
||||
) -> int:
|
||||
"""Return the cycle week index ``target_date`` falls in.
|
||||
|
||||
Anchor-date arithmetic ONLY (D-004): ``((target_date -
|
||||
anchor_date).days // 7) % cycle_length_weeks``. Python's floor
|
||||
division makes dates BEFORE the anchor behave correctly — they
|
||||
walk the cycle backwards (day -1 is the last week of the cycle,
|
||||
day -8 wraps one further) instead of inverting, which is exactly
|
||||
what ISO week parity does wrong in 53-week years. The modulo of
|
||||
a negative in Python is non-negative, so the index is always a
|
||||
valid week index.
|
||||
"""
|
||||
target = _parse_date(target_date, "target_date")
|
||||
days = (target - schedule.anchor_date).days
|
||||
return (days // 7) % schedule.cycle_length_weeks
|
||||
|
||||
|
||||
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
|
||||
#: years of daily checks. A child absent every day (an all-absent
|
||||
#: pattern with a blocking override) would otherwise loop forever; the
|
||||
#: cap bounds the scan and surfaces the situation as an error instead.
|
||||
MAX_PREVIEW_SCAN_DAYS = 366 * 2
|
||||
|
||||
|
||||
class PresenceEngine:
|
||||
"""Answers "is this child at this house on this date?".
|
||||
|
||||
Pure and side-effect-free: built from an immutable snapshot of the
|
||||
children's schedules (``child_id -> PresenceSchedule``), it never
|
||||
touches the database — callers build a snapshot from the DAO layer
|
||||
and pass it in. A child with NO schedule entry is present every
|
||||
day (the third household child's case); a child WITH a schedule is
|
||||
present exactly when the date's weekday is in that schedule's
|
||||
pattern for the date's cycle week index (anchor-date arithmetic —
|
||||
:func:`cycle_week_index`, never ISO week parity).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schedules: dict[int, PresenceSchedule],
|
||||
overrides: dict[int, list[PresenceOverride]] | None = None,
|
||||
) -> None:
|
||||
if not isinstance(schedules, dict):
|
||||
raise ValueError(
|
||||
f"schedules must map child_id to PresenceSchedule, got "
|
||||
f"{schedules!r}"
|
||||
)
|
||||
for child_id, schedule in schedules.items():
|
||||
# Validate the KEY as a model child id too: True and 1.0
|
||||
# compare equal to 1, so {True: schedule(1)} would be
|
||||
# accepted and then behave differently on lookup.
|
||||
_validate_child_id(child_id)
|
||||
if not isinstance(schedule, PresenceSchedule):
|
||||
raise ValueError(
|
||||
f"schedules[{child_id!r}] must be a PresenceSchedule, "
|
||||
f"got {schedule!r}"
|
||||
)
|
||||
if schedule.child_id != child_id:
|
||||
raise ValueError(
|
||||
f"schedules is keyed {child_id!r} but holds the "
|
||||
f"schedule of child {schedule.child_id}"
|
||||
)
|
||||
if overrides is None:
|
||||
overrides = {}
|
||||
if not isinstance(overrides, dict):
|
||||
raise ValueError(
|
||||
"overrides must map child_id to a list of "
|
||||
f"PresenceOverride, got {overrides!r}"
|
||||
)
|
||||
for child_id, entries in overrides.items():
|
||||
_validate_child_id(child_id)
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError(
|
||||
f"overrides[{child_id!r}] must be a list of "
|
||||
f"PresenceOverride, got {entries!r}"
|
||||
)
|
||||
for entry in entries:
|
||||
if not isinstance(entry, PresenceOverride):
|
||||
raise ValueError(
|
||||
f"overrides[{child_id!r}] entries must be "
|
||||
f"PresenceOverride, got {entry!r}"
|
||||
)
|
||||
if entry.child_id != child_id:
|
||||
raise ValueError(
|
||||
f"overrides is keyed {child_id!r} but holds an "
|
||||
f"override of child {entry.child_id}"
|
||||
)
|
||||
# Read-only snapshot: reassignment is refused and no mutator
|
||||
# exists, so a built engine answers consistently for its
|
||||
# lifetime. The overrides lists are copied to immutable
|
||||
# tuples — a shallow mapping copy alone would leave caller-
|
||||
# owned lists reachable through _overrides, and mutating one
|
||||
# later would change is_present results.
|
||||
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"_overrides",
|
||||
MappingProxyType(
|
||||
{child_id: tuple(entries) for child_id, entries in overrides.items()}
|
||||
),
|
||||
)
|
||||
|
||||
def is_present(self, child_id: object, date: object) -> bool:
|
||||
"""Return whether ``child_id`` is present on ``date``.
|
||||
|
||||
Overrides take precedence over the repeating pattern: when an
|
||||
override covers the date, its flag IS the answer — including
|
||||
for a child with no schedule at all. The DAO layer guarantees
|
||||
a child's overrides never overlap; if a snapshot still carries
|
||||
two covering the same date, the one with the LATEST start date
|
||||
wins (deterministic, and matches "the most specific swap wins").
|
||||
"""
|
||||
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||
target = _parse_date(date, "date")
|
||||
entries = self._overrides.get(child_id) or []
|
||||
covering = [
|
||||
entry for entry in entries if entry.covers(target)
|
||||
]
|
||||
if covering:
|
||||
winner = max(covering, key=lambda entry: entry.start_date)
|
||||
return winner.is_present
|
||||
schedule = self._schedules.get(child_id)
|
||||
if schedule is None:
|
||||
# No schedule at all: present every day (household default).
|
||||
return True
|
||||
week = cycle_week_index(schedule, target)
|
||||
return target.weekday() in schedule.pattern[week]
|
||||
|
||||
def next_present_dates(
|
||||
self,
|
||||
child_id: object,
|
||||
start_date: object,
|
||||
count: int,
|
||||
) -> list[datetime.date]:
|
||||
"""Return the next ``count`` dates the child is present.
|
||||
|
||||
Walks forward from ``start_date`` (INCLUSIVE — a parent
|
||||
previewing "starting Monday" expects Monday itself when the
|
||||
child is present) checking :meth:`is_present` for every day,
|
||||
so overrides and the pattern are honoured identically. The
|
||||
admin Schedule tab renders this preview so a parent can confirm
|
||||
a custody pattern is right before saving it.
|
||||
|
||||
A child absent every scanned day raises ValueError after the
|
||||
two-year search cap — an always-absent child would otherwise
|
||||
loop forever. ``count`` must be a positive integer.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
start = _parse_date(start_date, "start_date")
|
||||
if isinstance(count, bool) or not isinstance(count, int):
|
||||
raise ValueError(f"count must be an integer, got {count!r}")
|
||||
if count < 1:
|
||||
raise ValueError(f"count must be at least 1, got {count}")
|
||||
found: list[datetime.date] = []
|
||||
day = start
|
||||
for _ in range(MAX_PREVIEW_SCAN_DAYS):
|
||||
if self.is_present(child_id, day):
|
||||
found.append(day)
|
||||
if len(found) == count:
|
||||
return found
|
||||
day += datetime.timedelta(days=1)
|
||||
raise ValueError(
|
||||
f"child {child_id} is not present on any of the "
|
||||
f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; "
|
||||
f"{count} present day(s) cannot be previewed"
|
||||
)
|
||||
|
||||
def __setattr__(self, name: object, value: object) -> None:
|
||||
raise AttributeError(
|
||||
"PresenceEngine is an immutable snapshot; build a new engine "
|
||||
"instead of mutating this one"
|
||||
)
|
||||
@@ -0,0 +1,603 @@
|
||||
"""Business layer for creating and editing quest definitions (Feature 06).
|
||||
|
||||
Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
|
||||
all-or-nothing create/edit here, and never touch the DAO SQL directly.
|
||||
:func:`create_quest_definition` is the create path,
|
||||
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
|
||||
:func:`unassign_child` the assignment paths, and
|
||||
:func:`set_quest_definition_active` the deactivate/reactivate path.
|
||||
There is no permission check here (that is Feature 09).
|
||||
|
||||
Validation policy mirrors :mod:`.children`:
|
||||
|
||||
- ``title`` is required and trimmed; empty or whitespace-only raises
|
||||
ValueError.
|
||||
- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
|
||||
(its constructor is total — rule validation is NOT re-implemented
|
||||
here).
|
||||
- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``);
|
||||
every id is a plain int (bools and floats rejected, since SQLite would
|
||||
bind them onto a real child) and must reference an existing ACTIVE
|
||||
child (the DAO enforces the active/exists check inside the same
|
||||
transaction). Duplicate ids are rejected here, before the composite
|
||||
primary key could surface them as an :class:`sqlite3.IntegrityError`.
|
||||
- ``windows`` must be a non-empty list (strictly a ``list``); each entry
|
||||
is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare
|
||||
name (no due time) or a ``(name, due_time)`` pair whose due time is a
|
||||
strict 24-hour HH:MM string. Duplicate window names are rejected
|
||||
here, before the composite primary key could surface them as an
|
||||
:class:`sqlite3.IntegrityError`.
|
||||
|
||||
The rule, definition, assignees and windows are persisted in ONE
|
||||
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
|
||||
so a rejected or failed create persists nothing. The definition-level
|
||||
``due_time`` column is superseded by per-window due times (D-008) and is
|
||||
never set here. ``created_at`` is stamped UTC ISO-8601 at second
|
||||
precision, matching :mod:`.children`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import ChildRecord, _connection_lock
|
||||
from .dao_rules import (
|
||||
QuestDefinitionRecord,
|
||||
QuestDefinitionSnapshot,
|
||||
QuestDefinitionWindowRecord,
|
||||
QuestDefinitionsDao,
|
||||
ScheduleRuleStorage,
|
||||
ScheduleRulesDao,
|
||||
_UNSET,
|
||||
_validate_date,
|
||||
_validate_time,
|
||||
_validate_window,
|
||||
schedule_rule_from_storage,
|
||||
schedule_rule_storage_from_record,
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import regenerate_for_definition
|
||||
from .recurrence import ScheduleRule, occurs_on
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreatedQuestDefinition:
|
||||
"""The persisted result of a create, edit or activation change.
|
||||
|
||||
``definition`` is the stored row; ``rule`` is the schedule rule
|
||||
decoded back through the storage mapping; ``assignees`` and
|
||||
``windows`` are the definition's linked child records and window
|
||||
declarations, read through the DAO.
|
||||
"""
|
||||
|
||||
definition: QuestDefinitionRecord
|
||||
rule: ScheduleRule
|
||||
assignees: list[ChildRecord]
|
||||
windows: list[QuestDefinitionWindowRecord]
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_text(
|
||||
value: object, field: str, *, required: bool = False
|
||||
) -> str | None:
|
||||
"""Validate a free-text field and return it trimmed.
|
||||
|
||||
``None`` raises when ``required`` and otherwise returns None. A
|
||||
non-string value is rejected; an empty result is rejected for
|
||||
required fields.
|
||||
"""
|
||||
if value is None:
|
||||
if required:
|
||||
raise ValueError(f"{field} is required")
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||
trimmed = value.strip()
|
||||
if required and not trimmed:
|
||||
raise ValueError(f"{field} is required")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _validate_assignee_ids(value: object) -> list[int]:
|
||||
"""Return the assignee ids as plain ints, or raise naming the field.
|
||||
|
||||
The argument must be a non-empty list — strictly a ``list``: tuples
|
||||
and other sequences are rejected. Each entry must be a real int
|
||||
(bools and floats rejected) so SQLite cannot bind ``True`` onto
|
||||
child 1 or a float onto a neighbouring profile.
|
||||
"""
|
||||
if type(value) is not list:
|
||||
raise ValueError(
|
||||
f"assignee_child_ids must be a list, got {type(value).__name__}"
|
||||
)
|
||||
if not value:
|
||||
raise ValueError("assignee_child_ids must not be empty")
|
||||
ids: list[int] = []
|
||||
for entry in value:
|
||||
if isinstance(entry, bool) or not isinstance(entry, int):
|
||||
raise ValueError(
|
||||
"assignee_child_ids must contain integer child ids, "
|
||||
f"got {entry!r}"
|
||||
)
|
||||
ids.append(entry)
|
||||
if len(set(ids)) != len(ids):
|
||||
raise ValueError(
|
||||
"assignee_child_ids must not contain duplicate child ids"
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
def _normalize_windows(
|
||||
value: object,
|
||||
) -> list[tuple[str, str | None]]:
|
||||
"""Normalize the windows argument to ``(name, due_time)`` pairs.
|
||||
|
||||
The outer argument must be a non-empty list — strictly a ``list``:
|
||||
tuples and other sequences are rejected, while each individual entry
|
||||
may still be a ``(window, due_time)`` tuple. A bare window name
|
||||
(due time None) is also accepted. The name must be a
|
||||
:data:`~.const.QUEST_WINDOWS` spelling and a present due time must be
|
||||
a strict 24-hour HH:MM, both validated up front so a malformed
|
||||
window is rejected before any write.
|
||||
"""
|
||||
if type(value) is not list:
|
||||
raise ValueError(
|
||||
f"windows must be a list, got {type(value).__name__}"
|
||||
)
|
||||
if not value:
|
||||
raise ValueError("windows must not be empty")
|
||||
normalized: list[tuple[str, str | None]] = []
|
||||
seen: set[str] = set()
|
||||
for index, entry in enumerate(value):
|
||||
if isinstance(entry, str):
|
||||
name: object = entry
|
||||
due_time: object = None
|
||||
elif isinstance(entry, (tuple, list)) and len(entry) == 2:
|
||||
name, due_time = entry[0], entry[1]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"windows[{index}] must be a window name or a "
|
||||
f"(name, due_time) pair, got {entry!r}"
|
||||
)
|
||||
_validate_window(name)
|
||||
if due_time is not None:
|
||||
_validate_time(due_time, "due_time")
|
||||
if name in seen:
|
||||
raise ValueError(
|
||||
f"windows must not contain duplicate window {name!r}"
|
||||
)
|
||||
seen.add(name)
|
||||
normalized.append((name, due_time))
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_definition_id(value: object) -> int:
|
||||
"""Reject non-int definition ids (bools included) before any lookup.
|
||||
|
||||
SQLite binds Python bools as integers, so ``True`` would silently
|
||||
address definition 1 and a float would round — malformed service
|
||||
input must never mutate the wrong definition.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"definition_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools included) before any lookup.
|
||||
|
||||
SQLite binds Python bools as integers, so ``True`` would silently
|
||||
address child 1 and a float would round — malformed service input
|
||||
must never mutate the wrong profile.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def create_quest_definition(
|
||||
database: NestQuestDatabase,
|
||||
title: str,
|
||||
rule: ScheduleRule,
|
||||
assignee_child_ids: list[int],
|
||||
windows: list[str | tuple[str, str | None]],
|
||||
*,
|
||||
description: str | None = None,
|
||||
icon: str | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Create a quest definition (rule + assignees + windows) atomically.
|
||||
|
||||
Validates the arguments up front, then persists the schedule rule,
|
||||
the definition, its assignees and its windows in one transaction via
|
||||
the DAO. Returns the stored definition together with its decoded
|
||||
rule, assignees and windows — all read back inside that same
|
||||
transaction, so the returned bundle is a consistent creation
|
||||
snapshot. Raises ValueError on any rejected argument; a rejected or
|
||||
failed create persists nothing.
|
||||
"""
|
||||
name = _validate_text(title, "title", required=True)
|
||||
assert name is not None
|
||||
if not isinstance(rule, ScheduleRule):
|
||||
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
|
||||
assignee_ids = _validate_assignee_ids(assignee_child_ids)
|
||||
window_specs = _normalize_windows(windows)
|
||||
description_value = _validate_text(description, "description")
|
||||
icon_value = _validate_text(icon, "icon")
|
||||
|
||||
storage = schedule_rule_to_storage(rule)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
snapshot = await dao.create_with_rule_and_windows(
|
||||
name,
|
||||
storage,
|
||||
_now_stamp(),
|
||||
assignee_ids,
|
||||
window_specs,
|
||||
description=description_value,
|
||||
icon=icon_value,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's assignee check names only the child; re-raise so the
|
||||
# public contract names the field the caller actually passed.
|
||||
message = str(error)
|
||||
if message.startswith("child "):
|
||||
raise ValueError(
|
||||
f"assignee_child_ids: {message}"
|
||||
) from error
|
||||
raise
|
||||
decoded_rule = schedule_rule_from_storage(storage)
|
||||
return CreatedQuestDefinition(
|
||||
definition=snapshot.definition,
|
||||
rule=decoded_rule,
|
||||
assignees=snapshot.assignees,
|
||||
windows=snapshot.windows,
|
||||
)
|
||||
|
||||
|
||||
async def edit_quest_definition(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
*,
|
||||
title: str | object = _UNSET,
|
||||
description: str | None | object = _UNSET,
|
||||
icon: str | None | object = _UNSET,
|
||||
rule: ScheduleRule | object = _UNSET,
|
||||
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Edit a quest definition's metadata, rule and windows atomically.
|
||||
|
||||
Arguments default to the sentinel ``_UNSET`` meaning "leave this
|
||||
field alone". A provided ``title`` is validated like create's
|
||||
(required and trimmed); ``description`` and ``icon`` are optional
|
||||
and may be cleared by passing ``None`` explicitly. A provided
|
||||
``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
|
||||
and REPLACES the definition's schedule rule IN PLACE — the existing
|
||||
``schedule_rules`` row is updated, never orphaned or duplicated. A
|
||||
provided ``windows`` list REPLACES the whole window set (each entry
|
||||
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
|
||||
time), matching create's non-empty contract. Assignment is NOT
|
||||
settable here. Raises ValueError when the definition does not
|
||||
exist or any argument is rejected.
|
||||
|
||||
Returns the updated definition together with its decoded rule,
|
||||
assignees and windows — all read back inside the DAO's transaction,
|
||||
so the returned bundle is a consistent edit snapshot. Edits change
|
||||
future instances only; no ``quest_instances`` or
|
||||
``completion_events`` row is touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date the
|
||||
regeneration's no-past guard and horizon are anchored to (threaded
|
||||
into :func:`~.materialize.regenerate_for_definition`); it is a plain
|
||||
``datetime.date`` with no HA import here — the Feature 09 service
|
||||
supplies the hass-derived value. When omitted, regeneration falls
|
||||
back to the host clock, same as before. ``horizon_days`` sizes the
|
||||
regeneration's re-materialization window the same way (threaded into
|
||||
:func:`~.materialize.regenerate_for_definition`); it defaults to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS` when omitted.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
|
||||
title_value: str | None | object = _UNSET
|
||||
if title is not _UNSET:
|
||||
name = _validate_text(title, "title", required=True)
|
||||
assert name is not None
|
||||
title_value = name
|
||||
|
||||
description_value: str | None | object = _UNSET
|
||||
if description is not _UNSET:
|
||||
description_value = _validate_text(description, "description")
|
||||
|
||||
icon_value: str | None | object = _UNSET
|
||||
if icon is not _UNSET:
|
||||
icon_value = _validate_text(icon, "icon")
|
||||
|
||||
rule_storage: ScheduleRuleStorage | object = _UNSET
|
||||
if rule is not _UNSET:
|
||||
if not isinstance(rule, ScheduleRule):
|
||||
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
|
||||
rule_storage = schedule_rule_to_storage(rule)
|
||||
|
||||
window_specs: list[tuple[str, str | None]] | object = _UNSET
|
||||
if windows is not _UNSET:
|
||||
window_specs = _normalize_windows(windows)
|
||||
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
snapshot = await dao.edit_definition(
|
||||
definition_id,
|
||||
title=title_value,
|
||||
description=description_value,
|
||||
icon=icon_value,
|
||||
rule=rule_storage,
|
||||
windows=window_specs,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's missing-definition error names only the id; re-raise
|
||||
# so the public contract names the field the caller passed (matching
|
||||
# assign/unassign/deactivate).
|
||||
message = str(error)
|
||||
if message.startswith("quest definition "):
|
||||
raise ValueError(f"definition_id: {message}") from error
|
||||
raise
|
||||
|
||||
decoded_rule = schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(snapshot.rule)
|
||||
)
|
||||
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
|
||||
return CreatedQuestDefinition(
|
||||
definition=snapshot.definition,
|
||||
rule=decoded_rule,
|
||||
assignees=snapshot.assignees,
|
||||
windows=snapshot.windows,
|
||||
)
|
||||
|
||||
|
||||
async def assign_child(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> list[ChildRecord]:
|
||||
"""Assign ``child_id`` to the definition (idempotent).
|
||||
|
||||
Validates that both ids are plain ints (bools and floats rejected)
|
||||
and that the child exists and is active — the DAO enforces the
|
||||
active/exists check inside its transaction, and also confirms the
|
||||
definition exists. Assigning an already-assigned child is a no-op
|
||||
(no duplicate row). Returns the definition's assignees read back
|
||||
inside the DAO's same transaction, a consistent post-assignment
|
||||
roster (a concurrent assign/unassign cannot race it after the
|
||||
write). Raises ValueError on any rejected argument. Only the
|
||||
``quest_definition_assignees`` link is written; existing instances
|
||||
and completion history are never touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
into the assignment's regeneration (see :func:`edit_quest_definition`).
|
||||
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||
window the same way.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
_validate_child_id(child_id)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
assignees = await dao.add_assignee(definition_id, child_id)
|
||||
except ValueError as error:
|
||||
# The DAO names only the child/definition; re-raise so the
|
||||
# public contract names the field the caller actually passed.
|
||||
message = str(error)
|
||||
if message.startswith("child "):
|
||||
raise ValueError(f"child_id: {message}") from error
|
||||
if message.startswith("quest definition "):
|
||||
raise ValueError(f"definition_id: {message}") from error
|
||||
raise
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return assignees
|
||||
|
||||
|
||||
async def unassign_child(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> bool:
|
||||
"""Unassign ``child_id`` from the definition; True when removed.
|
||||
|
||||
Validates both ids are plain ints (bools and floats rejected) and
|
||||
that the definition exists, then removes the (definition, child)
|
||||
link. Returns True when a row was removed and False when the child
|
||||
was not currently assigned. Raises ValueError on a rejected
|
||||
argument. Only the ``quest_definition_assignees`` link is written;
|
||||
existing instances and completion history are never touched.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
into the unassignment's regeneration (see :func:`edit_quest_definition`).
|
||||
``horizon_days`` optionally sizes the regeneration's re-materialization
|
||||
window the same way.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
_validate_child_id(child_id)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
definition = await dao.get(definition_id)
|
||||
if definition is None:
|
||||
raise ValueError(
|
||||
f"definition_id: quest definition {definition_id} does not exist"
|
||||
)
|
||||
removed = await dao.remove_assignee(definition_id, child_id)
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return removed
|
||||
|
||||
|
||||
async def set_quest_definition_active(
|
||||
database: NestQuestDatabase,
|
||||
definition_id: int,
|
||||
is_active: bool,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Deactivate or reactivate a definition; returns the updated view.
|
||||
|
||||
Flipping the flag is the only removal path (Feature 06 guardrail):
|
||||
a deactivated definition stops future instance generation but its
|
||||
row, schedule rule, assignees and windows all survive, and existing
|
||||
instances plus completion history are never touched. Reactivation
|
||||
re-enables future generation; generating or removing instances is
|
||||
Feature 07 and does not happen here.
|
||||
|
||||
``definition_id`` must be a plain int (bools and floats rejected,
|
||||
since SQLite would bind ``True`` onto definition 1) and
|
||||
``is_active`` must be a real bool — the DAO's ``int()`` would
|
||||
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||
state the caller never asked for. Raises ValueError naming the
|
||||
offending field when the id is malformed, ``is_active`` is not a
|
||||
bool, or the definition does not exist.
|
||||
|
||||
Atomicity: the existence check, the UPDATE and the readback of the
|
||||
definition, its rule, assignees and windows all run inside the
|
||||
connection-scoped lock, so the returned bundle is a consistent
|
||||
snapshot that a concurrent mutation cannot race.
|
||||
|
||||
``today`` optionally pins the caller-resolved HA-local date threaded
|
||||
into the activation change's regeneration (see
|
||||
:func:`edit_quest_definition`). ``horizon_days`` optionally sizes the
|
||||
regeneration's re-materialization window the same way.
|
||||
"""
|
||||
_validate_definition_id(definition_id)
|
||||
if not isinstance(is_active, bool):
|
||||
raise ValueError(f"is_active must be a real bool, got {is_active!r}")
|
||||
async with _connection_lock(database):
|
||||
dao = QuestDefinitionsDao(database)
|
||||
if await dao.get(definition_id) is None:
|
||||
raise ValueError(
|
||||
f"definition_id: quest definition {definition_id} "
|
||||
"does not exist"
|
||||
)
|
||||
await dao.set_active(definition_id, is_active)
|
||||
updated = await dao.get(definition_id)
|
||||
assert updated is not None
|
||||
rule_record = await ScheduleRulesDao(database).get(
|
||||
updated.schedule_rule_id
|
||||
)
|
||||
assert rule_record is not None
|
||||
assignees = await dao.list_assignees(definition_id)
|
||||
windows = await dao.list_windows(definition_id)
|
||||
rule = schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(rule_record)
|
||||
)
|
||||
await regenerate_for_definition(
|
||||
database, definition_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return CreatedQuestDefinition(
|
||||
definition=updated,
|
||||
rule=rule,
|
||||
assignees=assignees,
|
||||
windows=windows,
|
||||
)
|
||||
|
||||
|
||||
def _bundle_snapshot(
|
||||
snapshot: QuestDefinitionSnapshot,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Decode a DAO snapshot into the layer's rich view.
|
||||
|
||||
The rule record carried by the snapshot is the one the definition
|
||||
referenced at read time, so decoding it here (rather than re-reading
|
||||
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
|
||||
coherence the DAO's locked snapshot guarantees: the rule, assignees
|
||||
and windows all come from one atomic read, never three separate ones.
|
||||
"""
|
||||
rule = schedule_rule_from_storage(
|
||||
schedule_rule_storage_from_record(snapshot.rule)
|
||||
)
|
||||
return CreatedQuestDefinition(
|
||||
definition=snapshot.definition,
|
||||
rule=rule,
|
||||
assignees=snapshot.assignees,
|
||||
windows=snapshot.windows,
|
||||
)
|
||||
|
||||
|
||||
async def list_active_definitions(
|
||||
database: NestQuestDatabase,
|
||||
) -> list[CreatedQuestDefinition]:
|
||||
"""Return every ACTIVE definition with its decoded rule and links.
|
||||
|
||||
Ordered by rising definition id, matching the DAO's ``list_active``
|
||||
order, so callers get a stable, repeatable sequence. Each entry is
|
||||
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
|
||||
the layer: the stored row plus its decoded
|
||||
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
|
||||
definitions and their links are read by the DAO inside one locked
|
||||
transaction, so each bundle is a coherent snapshot that a concurrent
|
||||
edit cannot interleave.
|
||||
"""
|
||||
dao = QuestDefinitionsDao(database)
|
||||
snapshots = await dao.list_snapshots_active()
|
||||
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
|
||||
|
||||
async def list_definitions_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
) -> list[CreatedQuestDefinition]:
|
||||
"""Return the definitions assigned to ``child_id`` (rich view).
|
||||
|
||||
``child_id`` must be a plain int (bools and floats rejected, since
|
||||
SQLite would bind ``True`` onto child 1). Results are ordered by
|
||||
rising definition id, matching ``list_active_definitions`` and
|
||||
``list_definitions_firing_on``. Each entry is a
|
||||
:class:`CreatedQuestDefinition` snapshot bundling the definition with
|
||||
its decoded rule, assignees and windows, read atomically by the DAO.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
snapshots = await dao.list_snapshots_by_child(child_id)
|
||||
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
return sorted(bundles, key=lambda bundle: bundle.definition.id)
|
||||
|
||||
|
||||
async def list_definitions_firing_on(
|
||||
database: NestQuestDatabase,
|
||||
date: str,
|
||||
) -> list[CreatedQuestDefinition]:
|
||||
"""Return the ACTIVE definitions whose rule fires on ``date``.
|
||||
|
||||
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
|
||||
value raises ValueError naming the field. Each active definition's
|
||||
stored rule is decoded through the task-1 storage mapping and handed
|
||||
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
|
||||
definitions whose decoded rule fires on the date are returned, in
|
||||
rising definition-id order, each as the same rich
|
||||
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
|
||||
The definitions and their rules are read inside one locked
|
||||
transaction, so a bundle can never filter on one rule while
|
||||
presenting another state's windows.
|
||||
"""
|
||||
_validate_date(date, "date")
|
||||
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
|
||||
dao = QuestDefinitionsDao(database)
|
||||
snapshots = await dao.list_snapshots_active()
|
||||
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
|
||||
@@ -0,0 +1,621 @@
|
||||
"""The ScheduleRule model: rule types, validation, and serialization.
|
||||
|
||||
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
|
||||
frozen dataclass with no Home Assistant and no database imports. The
|
||||
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
|
||||
serializes its dict form into the ``schedule_rules`` table.
|
||||
|
||||
Type model: one enum member per recurrence shape. ``MONTHLY_DAY``
|
||||
("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second
|
||||
Tuesday of every month") are distinct types rather than one monthly
|
||||
type with optional fields, so validation is total: a rule either
|
||||
constructs, or raises :class:`RuleValidationError` naming the offending
|
||||
field. Every type carries ONLY the fields its shape needs; carrying
|
||||
any other shape's field is a construction error.
|
||||
|
||||
Storage mapping (Feature 02, ``schedule_rules`` table): the enum's
|
||||
``storage_value`` maps model types onto the schema's CHECK values —
|
||||
MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are
|
||||
disambiguated by which fields are populated; CUSTOM_DAYS stores as
|
||||
``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the
|
||||
table stores (one element for MONTHLY_WEEKDAY, which names the
|
||||
position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field
|
||||
carried through from_dict/to_dict for losslessness and folded into
|
||||
``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the
|
||||
model-side lossless pair; the DB mapping lives in the DAO layer and is
|
||||
exercised there.
|
||||
|
||||
Validation policy (fail fast at construction — the schema's CHECK
|
||||
constraints are a second line of defense, but invalid rules must never
|
||||
reach storage):
|
||||
|
||||
- ``interval`` >= 1 for every rule.
|
||||
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
|
||||
elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans.
|
||||
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
|
||||
- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is
|
||||
rejected) with a weekday 0..6 for MONTHLY_WEEKDAY.
|
||||
- ``month`` 1..12 is required for YEARLY.
|
||||
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
|
||||
when present, on or after ``start_date``.
|
||||
|
||||
Month-end policy (implemented in :func:`_occurs_monthly_day` and
|
||||
documented for Feature 04): in each interval-eligible month, a
|
||||
MONTHLY_DAY rule for a day that does not exist in that month — the
|
||||
31st in April, the 30th or 29th in February, February 29th in a
|
||||
non-leap year — fires on the LAST day of the month instead of being
|
||||
skipped. A monthly chore still happens in every eligible month,
|
||||
pinned to the month's end when its nominal date is absent; exactly one
|
||||
day per eligible month matches the rule (nominal day when it exists,
|
||||
month end when it does not). The interval anchors on nominal months
|
||||
elapsed from start_date's month, so clamping never shifts the cadence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
|
||||
_DATE_FORMAT = "%Y-%m-%d"
|
||||
|
||||
#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order).
|
||||
|
||||
|
||||
class RuleType(enum.Enum):
|
||||
"""The six recurrence shapes the engine supports.
|
||||
|
||||
``storage_value`` is the string written to the ``schedule_rules``
|
||||
table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY
|
||||
and MONTHLY_WEEKDAY are distinct shapes sharing one storage value,
|
||||
disambiguated by which fields are populated.
|
||||
"""
|
||||
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY_DAY = "monthly_day"
|
||||
MONTHLY_WEEKDAY = "monthly_weekday"
|
||||
YEARLY = "yearly"
|
||||
CUSTOM_DAYS = "custom_days"
|
||||
|
||||
@property
|
||||
def storage_value(self) -> str:
|
||||
"""The schema-level rule_type string this model type maps to."""
|
||||
return {
|
||||
RuleType.DAILY: "daily",
|
||||
RuleType.WEEKLY: "weekly",
|
||||
RuleType.MONTHLY_DAY: "monthly",
|
||||
RuleType.MONTHLY_WEEKDAY: "monthly",
|
||||
RuleType.YEARLY: "yearly",
|
||||
RuleType.CUSTOM_DAYS: "custom",
|
||||
}[self]
|
||||
|
||||
|
||||
class RuleValidationError(ValueError):
|
||||
"""Raised when a ScheduleRule field combination is invalid."""
|
||||
|
||||
|
||||
def _validate_date(value: str, field_name: str) -> None:
|
||||
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
except (TypeError, ValueError):
|
||||
raise RuleValidationError(
|
||||
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() != value:
|
||||
raise RuleValidationError(
|
||||
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_weekday_set(weekday_set) -> frozenset[int]:
|
||||
"""Return a normalized frozenset or raise with a clear message.
|
||||
|
||||
Sequence entries are validated BEFORE set() coercion: a list like
|
||||
[0, False] must not collapse the boolean into 0, and unhashable
|
||||
entries must raise RuleValidationError, never a raw TypeError.
|
||||
"""
|
||||
if isinstance(weekday_set, (list, tuple)):
|
||||
for entry in weekday_set:
|
||||
# _is_plain_int: bool and int subclasses (potentially
|
||||
# unhashable) are rejected before set() coercion.
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
weekday_set = set(weekday_set)
|
||||
if not isinstance(weekday_set, (set, frozenset)):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set must be a set of weekday integers, got "
|
||||
f"{type(weekday_set).__name__}"
|
||||
)
|
||||
if not weekday_set:
|
||||
raise RuleValidationError(
|
||||
"weekday_set must not be empty for weekly/custom rules"
|
||||
)
|
||||
for entry in weekday_set:
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
if not 0 <= entry <= 6:
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
|
||||
f"got {entry!r}"
|
||||
)
|
||||
return frozenset(weekday_set)
|
||||
|
||||
|
||||
def _is_plain_int(value) -> bool:
|
||||
"""True only for real ints: bool excluded AND int subclasses too
|
||||
(an unhashable int subclass would break the frozen-hashable model)."""
|
||||
return type(value) is int
|
||||
|
||||
|
||||
def _validate_monthly_weekday(nth_weekday, weekday) -> None:
|
||||
"""Validate the MONTHLY_WEEKDAY position fields."""
|
||||
if nth_weekday is None or not _is_plain_int(nth_weekday):
|
||||
raise RuleValidationError(
|
||||
"nth_weekday must be an integer for monthly_weekday rules, "
|
||||
f"got {nth_weekday!r}"
|
||||
)
|
||||
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
|
||||
raise RuleValidationError(
|
||||
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
|
||||
)
|
||||
if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6:
|
||||
raise RuleValidationError(
|
||||
f"monthly_weekday rules require a weekday 0 (Monday)..6 "
|
||||
f"(Sunday), got {weekday!r}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleRule:
|
||||
"""An immutable, validated recurrence rule (see module docstring).
|
||||
|
||||
Construct with keyword arguments; invalid combinations raise
|
||||
:class:`RuleValidationError` at construction time. Fields map
|
||||
one-to-one onto the ``schedule_rules`` table columns, with
|
||||
``nth_weekday_weekday`` as the model-only weekday companion to
|
||||
``nth_weekday`` (serialized inside ``weekday_set`` at the DAO
|
||||
boundary).
|
||||
"""
|
||||
|
||||
rule_type: RuleType
|
||||
interval: int = 1
|
||||
weekday_set: frozenset[int] | None = None
|
||||
day_of_month: int | None = None
|
||||
nth_weekday: int | None = None
|
||||
nth_weekday_weekday: int | None = None
|
||||
month: int | None = None
|
||||
start_date: str = "1970-01-01"
|
||||
end_date: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Normalize a string rule_type into the enum; anything else
|
||||
# that is not already a RuleType is rejected.
|
||||
if isinstance(self.rule_type, str):
|
||||
# EXACT model names only: no whitespace stripping, no storage
|
||||
# aliases. The DAO maps storage strings to model fields when
|
||||
# reading rows; the model boundary stays unambiguous.
|
||||
candidates = {rt.name.lower(): rt for rt in RuleType}
|
||||
if self.rule_type in candidates:
|
||||
object.__setattr__(
|
||||
self, "rule_type", candidates[self.rule_type]
|
||||
)
|
||||
else:
|
||||
raise RuleValidationError(
|
||||
f"rule_type must be a RuleType or one of "
|
||||
f"{sorted(candidates)}, got {self.rule_type!r}"
|
||||
)
|
||||
if not isinstance(self.rule_type, RuleType):
|
||||
raise RuleValidationError(
|
||||
f"rule_type must be a RuleType or one of its string "
|
||||
f"values, got {self.rule_type!r}"
|
||||
)
|
||||
if not _is_plain_int(self.interval):
|
||||
raise RuleValidationError(
|
||||
f"interval must be an integer >= 1, got {self.interval!r}"
|
||||
)
|
||||
if self.interval < 1:
|
||||
raise RuleValidationError(
|
||||
f"interval must be >= 1, got {self.interval!r}"
|
||||
)
|
||||
|
||||
_validate_date(self.start_date, "start_date")
|
||||
if self.end_date is not None:
|
||||
_validate_date(self.end_date, "end_date")
|
||||
if self.end_date < self.start_date:
|
||||
raise RuleValidationError(
|
||||
f"end_date {self.end_date!r} must be on or after "
|
||||
f"start_date {self.start_date!r}"
|
||||
)
|
||||
|
||||
# Type-exclusive field policy, enforced for every type: build
|
||||
# the dict of present fields, then reject every field the shape
|
||||
# must not carry (weekday_set present is detectable via None vs
|
||||
# set even after normalization below).
|
||||
shape = self.rule_type
|
||||
if shape is RuleType.DAILY:
|
||||
forbidden_fields = (
|
||||
"weekday_set",
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.WEEKLY:
|
||||
forbidden_fields = (
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.CUSTOM_DAYS:
|
||||
forbidden_fields = (
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.MONTHLY_DAY:
|
||||
forbidden_fields = ("weekday_set", "nth_weekday",
|
||||
"nth_weekday_weekday", "month")
|
||||
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||
forbidden_fields = ("weekday_set", "day_of_month", "month")
|
||||
elif shape is RuleType.YEARLY:
|
||||
forbidden_fields = (
|
||||
"weekday_set",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
)
|
||||
present_forbidden = [
|
||||
name
|
||||
for name in forbidden_fields
|
||||
if getattr(self, name) is not None
|
||||
]
|
||||
if present_forbidden:
|
||||
raise RuleValidationError(
|
||||
f"{shape.value} rules must not set "
|
||||
f"{', '.join(sorted(present_forbidden))}"
|
||||
)
|
||||
|
||||
# Required fields per shape.
|
||||
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
|
||||
normalized = _validate_weekday_set(self.weekday_set)
|
||||
object.__setattr__(self, "weekday_set", normalized)
|
||||
elif shape is RuleType.MONTHLY_DAY:
|
||||
if self.day_of_month is None or not _is_plain_int(
|
||||
self.day_of_month
|
||||
):
|
||||
raise RuleValidationError(
|
||||
"monthly_day rules require day_of_month (1..31)"
|
||||
)
|
||||
if not 1 <= self.day_of_month <= 31:
|
||||
raise RuleValidationError(
|
||||
f"day_of_month must be 1..31, got {self.day_of_month!r}"
|
||||
)
|
||||
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||
_validate_monthly_weekday(
|
||||
self.nth_weekday, self.nth_weekday_weekday
|
||||
)
|
||||
elif shape is RuleType.YEARLY:
|
||||
if self.month is None or not _is_plain_int(self.month):
|
||||
raise RuleValidationError(
|
||||
"yearly rules require month (1..12)"
|
||||
)
|
||||
if not 1 <= self.month <= 12:
|
||||
raise RuleValidationError(
|
||||
f"month must be 1..12, got {self.month!r}"
|
||||
)
|
||||
if self.day_of_month is not None and not (
|
||||
_is_plain_int(self.day_of_month)
|
||||
and 1 <= self.day_of_month <= 31
|
||||
):
|
||||
raise RuleValidationError(
|
||||
f"day_of_month must be 1..31 for yearly rules, got "
|
||||
f"{self.day_of_month!r}"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Return the lossless dict form (model-side storage shape).
|
||||
|
||||
``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's
|
||||
weekday travels inside it (appended) plus
|
||||
``nth_weekday_weekday`` so the pair round-trips without
|
||||
ambiguity at the model layer.
|
||||
"""
|
||||
return {
|
||||
"rule_type": self.rule_type.name.lower(), # model name
|
||||
"interval": self.interval,
|
||||
"weekday_set": (
|
||||
sorted(self.weekday_set) if self.weekday_set is not None
|
||||
else None
|
||||
),
|
||||
"day_of_month": self.day_of_month,
|
||||
"nth_weekday": self.nth_weekday,
|
||||
"nth_weekday_weekday": self.nth_weekday_weekday,
|
||||
"month": self.month,
|
||||
"start_date": self.start_date,
|
||||
"end_date": self.end_date,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ScheduleRule":
|
||||
"""Rebuild a rule from :meth:`to_dict` output.
|
||||
|
||||
Raises :class:`RuleValidationError` on unknown keys, missing
|
||||
keys, or invalid combinations — never a raw TypeError.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise RuleValidationError(
|
||||
f"rule data must be a dict, got {type(data).__name__}"
|
||||
)
|
||||
allowed = {
|
||||
"rule_type",
|
||||
"interval",
|
||||
"weekday_set",
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
"start_date",
|
||||
"end_date",
|
||||
}
|
||||
unknown = [key for key in data if key not in allowed]
|
||||
if unknown:
|
||||
raise RuleValidationError(
|
||||
f"unknown rule fields: {sorted(unknown, key=repr)}"
|
||||
)
|
||||
if "rule_type" not in data:
|
||||
raise RuleValidationError("missing required rule field: rule_type")
|
||||
weekday_set = data.get("weekday_set")
|
||||
if isinstance(weekday_set, (list, tuple)):
|
||||
# Validate entries as plain ints BEFORE set() coercion, so a
|
||||
# list like [0, False] cannot collapse False into 0 and an
|
||||
# unhashable/int-subclass entry cannot leak a raw TypeError.
|
||||
for entry in weekday_set:
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
weekday_set = set(weekday_set)
|
||||
return cls(
|
||||
rule_type=data["rule_type"],
|
||||
interval=data.get("interval", 1),
|
||||
weekday_set=weekday_set,
|
||||
day_of_month=data.get("day_of_month"),
|
||||
nth_weekday=data.get("nth_weekday"),
|
||||
nth_weekday_weekday=data.get("nth_weekday_weekday"),
|
||||
month=data.get("month"),
|
||||
start_date=data.get("start_date", "1970-01-01"),
|
||||
end_date=data.get("end_date"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_date(value: str) -> datetime.date:
|
||||
"""Parse a strict ISO date already validated by the model."""
|
||||
return datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
|
||||
|
||||
def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int:
|
||||
"""Day offset from the rule's anchor date to ``target``.
|
||||
|
||||
The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the
|
||||
first day the rule CAN fire. A negative offset means the target
|
||||
precedes the rule's window entirely.
|
||||
"""
|
||||
return (target - _parse_date(rule.start_date)).days
|
||||
|
||||
|
||||
def _within_window(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""True when ``target`` is inside the rule's [start, end] window."""
|
||||
if target < _parse_date(rule.start_date):
|
||||
return False
|
||||
if rule.end_date is not None and target > _parse_date(rule.end_date):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _month_end(year: int, month: int) -> int:
|
||||
"""The last calendar day of the given month (leap-aware)."""
|
||||
if month == 12:
|
||||
return 31
|
||||
return (datetime.date(year, month + 1, 1)
|
||||
- datetime.timedelta(days=1)).day
|
||||
|
||||
|
||||
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""MONTHLY_DAY evaluation with the documented month-end policy.
|
||||
|
||||
Policy: in each interval-eligible month, a rule for a day-of-month
|
||||
that does not exist in that month (the 31st in April, the 30th in
|
||||
February, the 29th of February in a non-leap year) fires on the
|
||||
LAST day of that month instead of being skipped — the chore still
|
||||
happens that month, pinned to the month's end when its nominal date
|
||||
is absent.
|
||||
|
||||
The interval anchors on months ELAPSED from start_date's month: the
|
||||
rule fires when the months between the anchor month and the target's
|
||||
month form a multiple of the interval, keeping "every 2 months on
|
||||
the 15th" aligned to the anchor month even when the firing lands on
|
||||
a clamped month-end day.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
months_elapsed = (
|
||||
(target.year - anchor.year) * 12 + (target.month - anchor.month)
|
||||
)
|
||||
if months_elapsed % rule.interval != 0:
|
||||
return False
|
||||
month_end = _month_end(target.year, target.month)
|
||||
return target.day == min(rule.day_of_month, month_end)
|
||||
|
||||
|
||||
def _nth_weekday_of_month(
|
||||
year: int, month: int, weekday: int, nth: int
|
||||
) -> int | None:
|
||||
"""The day-of-month of the ``nth`` ``weekday``, or None when the
|
||||
month has no such occurrence (``nth`` 5 beyond four occurrences).
|
||||
|
||||
Positive ``nth`` counts forward (1 = first); ``-1`` counts from the
|
||||
end (last occurrence, whatever the total). Pure calendar math.
|
||||
"""
|
||||
if nth == -1:
|
||||
last = datetime.date(year, month, _month_end(year, month))
|
||||
back = (last.weekday() - weekday) % 7
|
||||
return last.day - back
|
||||
first = datetime.date(year, month, 1)
|
||||
first_offset = (weekday - first.weekday()) % 7
|
||||
day = 1 + first_offset + (nth - 1) * 7
|
||||
if day > _month_end(year, month):
|
||||
return None
|
||||
return day
|
||||
|
||||
|
||||
def _occurs_monthly_weekday(
|
||||
rule: ScheduleRule, target: datetime.date
|
||||
) -> bool:
|
||||
"""MONTHLY_WEEKDAY evaluation.
|
||||
|
||||
Fires when the target is the ``nth`` occurrence of
|
||||
``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A
|
||||
month with fewer occurrences than ``nth`` (5 on a four-occurrence
|
||||
month) does not fire — n 5 is accepted by the model but skips such
|
||||
months, matching the done-condition. The interval anchors on
|
||||
nominal months elapsed from start_date's month.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
months_elapsed = (
|
||||
(target.year - anchor.year) * 12 + (target.month - anchor.month)
|
||||
)
|
||||
if months_elapsed % rule.interval != 0:
|
||||
return False
|
||||
if target.weekday() != rule.nth_weekday_weekday:
|
||||
return False
|
||||
nth_day = _nth_weekday_of_month(
|
||||
target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday
|
||||
)
|
||||
return nth_day is not None and target.day == nth_day
|
||||
|
||||
|
||||
def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""YEARLY evaluation with the documented leap-day clamp policy.
|
||||
|
||||
Policy: in each interval-eligible year, the rule fires on
|
||||
``day_of_month`` of ``month`` when that day exists. A YEARLY rule
|
||||
for February 29th fires on FEBRUARY 28TH in a non-leap year (the
|
||||
same clamp-as-month-end policy as MONTHLY_DAY: the annual chore
|
||||
still happens every year, pinned to February's end when its
|
||||
nominal date is absent) rather than silently vanishing. A YEARLY
|
||||
rule with NO day_of_month fires on the first day of ``month``.
|
||||
The interval anchors on years elapsed from start_date's year.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
years_elapsed = target.year - anchor.year
|
||||
if years_elapsed % rule.interval != 0:
|
||||
return False
|
||||
if target.month != rule.month:
|
||||
return False
|
||||
if rule.day_of_month is None:
|
||||
# Day-less yearly rules fire on the first of the month.
|
||||
return target.day == 1
|
||||
month_end = _month_end(target.year, target.month)
|
||||
return target.day == min(rule.day_of_month, month_end)
|
||||
|
||||
|
||||
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""Return True when ``rule`` fires on ``target``.
|
||||
|
||||
Pure function: no I/O, no timezone awareness, no mutation. Dates
|
||||
are plain calendar dates (Feature 04 guardrail); the caller decides
|
||||
the timezone that produced them.
|
||||
"""
|
||||
# Dispatch FIRST: an unimplemented shape must raise loudly even for
|
||||
# dates outside the window, never silently evaluate to False.
|
||||
shape = rule.rule_type
|
||||
if shape not in (
|
||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
|
||||
):
|
||||
raise NotImplementedError(
|
||||
f"occurs_on for {shape} lands with its own engine task "
|
||||
"(Features 04 task sequence)"
|
||||
)
|
||||
if not _within_window(rule, target):
|
||||
return False
|
||||
if shape is RuleType.DAILY:
|
||||
offset = (target - _parse_date(rule.start_date)).days
|
||||
return offset % rule.interval == 0
|
||||
if shape is RuleType.MONTHLY_DAY:
|
||||
return _occurs_monthly_day(rule, target)
|
||||
if shape is RuleType.MONTHLY_WEEKDAY:
|
||||
return _occurs_monthly_weekday(rule, target)
|
||||
if shape is RuleType.YEARLY:
|
||||
return _occurs_yearly(rule, target)
|
||||
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
|
||||
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
|
||||
# parity on January 1st; anchor arithmetic does not).
|
||||
anchor = _parse_date(rule.start_date)
|
||||
if shape is RuleType.CUSTOM_DAYS:
|
||||
interval = 1
|
||||
else:
|
||||
interval = rule.interval
|
||||
week_offset = (target - anchor).days // 7
|
||||
if week_offset % interval != 0:
|
||||
return False
|
||||
return target.weekday() in rule.weekday_set
|
||||
|
||||
|
||||
def occurrences_between(
|
||||
rule: ScheduleRule, start: str, end: str
|
||||
) -> list[str]:
|
||||
"""Return every date string in [start, end] the rule fires on.
|
||||
|
||||
Inclusive on both ends. Raises RuleValidationError on malformed or
|
||||
inverted bounds; a window entirely outside [rule.start_date,
|
||||
rule.end_date] yields an empty list.
|
||||
"""
|
||||
if rule.rule_type not in (
|
||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
|
||||
):
|
||||
raise NotImplementedError(
|
||||
f"occurrences_between for {rule.rule_type} lands with its own "
|
||||
"engine task (Features 04 task sequence)"
|
||||
)
|
||||
_validate_date(start, "start")
|
||||
_validate_date(end, "end")
|
||||
start_date = _parse_date(start)
|
||||
end_date = _parse_date(end)
|
||||
if end_date < start_date:
|
||||
raise RuleValidationError(
|
||||
f"end {end!r} must be on or after start {start!r}"
|
||||
)
|
||||
rule_start = _parse_date(rule.start_date)
|
||||
if end_date < rule_start:
|
||||
return []
|
||||
# Clamp the walk to the rule's own window and the queried range.
|
||||
effective_start = max(start_date, rule_start)
|
||||
effective_end = end_date
|
||||
if rule.end_date is not None:
|
||||
effective_end = min(effective_end, _parse_date(rule.end_date))
|
||||
results: list[str] = []
|
||||
cursor = effective_start
|
||||
while cursor <= effective_end:
|
||||
if occurs_on(rule, cursor):
|
||||
results.append(cursor.isoformat())
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return results
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Declarative schema DDL for the NestQuest SQLite database.
|
||||
|
||||
Every v1 table is declared here as plain SQL statements so later schema
|
||||
tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without
|
||||
touching this module's tables. All statements use
|
||||
``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned
|
||||
migration runner is a separate later concern.
|
||||
|
||||
Timestamp policy: all timestamps are UTC ISO-8601 strings, set by the
|
||||
caller/DAO rather than a DB default, per the feature guardrails. No
|
||||
``length(created_at) = 25`` CHECK is added: the exact string shape
|
||||
produced by callers is not fixed yet (``datetime.isoformat()`` may
|
||||
include microseconds, changing the length), so enforcing a fixed length
|
||||
here would reject valid values. Only non-null is enforced for now.
|
||||
|
||||
Date policy: ``start_date``/``end_date`` are ISO date strings (YYYY-MM-DD,
|
||||
no time component) compared lexically by the ``end_date >= start_date``
|
||||
CHECK; due_date/due_time string shapes are caller-validated by the rule
|
||||
engine and DAO layers, not by DDL.
|
||||
|
||||
``weekday_set`` format: comma-separated weekday integers (CSV), 0 =
|
||||
Monday .. 6 = Sunday, e.g. ``'0,2,4'`` for Mon/Wed/Fri. Chosen over JSON
|
||||
because it sorts, compares and parses trivially in SQL. The format is
|
||||
enforced at the DB level: a CHECK matches ``weekday_set`` against GLOB
|
||||
patterns for element counts 1..7 of single digits 0-6 (GLOB has no
|
||||
repetition quantifier, so the element count is enumerated; more than 7
|
||||
elements is impossible for a weekday set). ``''``, ``'x'``, ``'7'`` and
|
||||
``'0,x'`` are all rejected; NULL stays allowed for non-weekly rules.
|
||||
This keeps the schema layer storage-only — the DAO layer (tasks
|
||||
8c72300c+) does not need its own validator, it can rely on the CHECK.
|
||||
|
||||
Type affinity: SQLite applies column affinity on insert, but CHECKs
|
||||
still observe text and fractional values ('abc' stays text, '1.5'
|
||||
becomes REAL), so every INTEGER column carrying rule semantics carries
|
||||
a ``typeof(...) = 'integer'`` guard in its CHECK.
|
||||
|
||||
Rule-type coherence: two CHECKs enforce that ``weekly`` carries a
|
||||
non-null ``weekday_set``, ``monthly`` carries a ``day_of_month`` or an
|
||||
``nth_weekday``, ``yearly`` carries a ``month``, and ``daily``/``custom``
|
||||
carry none of the month-specific fields. Relaxed deliberately: the DDL
|
||||
does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks"
|
||||
rule or a monthly rule could later be pinned to a month) nor extra
|
||||
fields on weekly/monthly/yearly rules generally, so the CHECKs only
|
||||
reject what the rule engine definitively cannot express now.
|
||||
|
||||
Presence pattern format: ``presence_schedules.pattern`` encodes the
|
||||
whole N-week repeating cycle in one TEXT column as pipe-separated
|
||||
segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
|
||||
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
|
||||
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
|
||||
week 1. An empty segment means the child is absent every day of that
|
||||
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means
|
||||
absent every day of every week, which is distinct from having no
|
||||
schedule row at all (a child with no schedule is present every day, per
|
||||
the feature guardrails). Chosen over JSON or per-week rows to stay
|
||||
consistent with the CSV ``weekday_set`` convention and fully checkable
|
||||
by plain CHECKs. DB-level validation is pure CHECK, no triggers or
|
||||
custom functions: char-class GLOBs reject every character outside
|
||||
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
|
||||
(double comma, comma next to a pipe, leading/trailing comma) and
|
||||
two-digit runs; a further GLOB rejects contiguous runs of 8+ comma
|
||||
separated digits, which caps a segment at 7 elements — the GLOB cannot
|
||||
match across a ``|``, so cross-segment boundaries stay safe; and the
|
||||
segment count is pinned to ``cycle_length_weeks`` by
|
||||
``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per
|
||||
weekday_set this is not full CSV-set normalisation (duplicates like
|
||||
``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``);
|
||||
the engine treats the segment as a set. A
|
||||
one-row-per-``week_index`` normalization was considered and rejected:
|
||||
the flat single-column encoding keeps the done-condition's table shape
|
||||
and proved fully checkable with plain CHECKs.
|
||||
|
||||
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
|
||||
common case, and a small cap keeps anchor arithmetic and the encoded
|
||||
pattern human-readable. Revisit the cap — and re-run the pattern
|
||||
validation matrix in tests — if a longer cycle is ever needed.
|
||||
``anchor_date`` is an ISO date string pinning the cycle: the week index
|
||||
of a date is (days since anchor) // 7 mod ``cycle_length_weeks``,
|
||||
computed by the presence engine (Feature 05) from anchor-date
|
||||
arithmetic, never ISO week parity. Like other date columns it carries
|
||||
no shape CHECK, matching the established date policy.
|
||||
|
||||
Presence overrides: ``presence_overrides.end_date`` is NOT NULL,
|
||||
unlike ``schedule_rules.end_date`` — an override is always a concrete
|
||||
date range; a single-day override stores the same date in both columns.
|
||||
``is_present`` 0/1 marks the child absent/present for the whole range;
|
||||
``note`` is optional free text.
|
||||
|
||||
Multi-assignee definitions (D-008): ``quest_definitions`` carries no
|
||||
``child_id`` column. Assignment lives in
|
||||
``quest_definition_assignees``, one row per (definition, child) with a
|
||||
composite primary key and foreign keys to the definition and the child,
|
||||
so "brush teeth" is one definition covering three children. The
|
||||
migration runner rebuilds a pre-D-008 table into this shape and copies
|
||||
its single-assignee column across. The composite primary key makes
|
||||
duplicate assignment impossible at the storage layer.
|
||||
|
||||
Quest windows (D-008): ``quest_definition_windows`` declares which day
|
||||
windows a definition spans — one row per (definition, window), the
|
||||
composite primary key making a window idempotent per definition.
|
||||
``window`` is CHECK-constrained to the three spellings ``const`` owns;
|
||||
``due_time`` optionally pins a due time inside the window and is
|
||||
caller-validated (strict HH:MM) by the DAO layer, matching the date
|
||||
policy above. The window clock ranges live in ``const.py``, not here:
|
||||
this layer stores what the caller declares and never interprets clocks.
|
||||
|
||||
Quest instances: ``quest_instances`` deliberately carries NO completion
|
||||
status column. An instance's current state derives from the latest row
|
||||
in ``completion_events`` (Feature 08), so materialization (Feature 07)
|
||||
can insert instances without knowing anything about completion and the
|
||||
append-only log stays the single source of truth. ``definition_id``,
|
||||
``child_id``, ``due_date`` and ``window`` snapshot the
|
||||
materialization-time facts ("why this instance exists"): assignment
|
||||
edits and rule edits change future instances only and never rewrite
|
||||
these rows. ``window`` records which declared day window produced the
|
||||
instance (D-008), CHECK-constrained to the same three spellings
|
||||
``const`` owns. ``due_time`` snapshots the window's optional due time
|
||||
at generation for the same reason — the instance keeps its
|
||||
generation-time value even if the definition is edited later.
|
||||
``generated_at`` is a caller-set UTC ISO-8601 timestamp per the module
|
||||
timestamp policy (no DB default, so a generation batch stamps one
|
||||
coherent time).
|
||||
|
||||
Uniqueness: the (definition_id, child_id, due_date, window) tuple is
|
||||
declared as a table-level ``UNIQUE`` constraint rather than a separate
|
||||
``CREATE UNIQUE INDEX`` statement. SQLite materializes it as an
|
||||
implicit unique index (``sqlite_autoindex_quest_instances_1``) — the
|
||||
required "unique index prevents duplicate instances" — and the
|
||||
constraint form cannot be silently dropped the way a standalone index
|
||||
can, so the duplicate door stays shut. Idempotent materialization keys
|
||||
on exactly this tuple: the twice-daily case (one definition, two
|
||||
windows, one child, one date) yields two rows and the shared case (one
|
||||
definition, three assignees, one window) yields three. No secondary
|
||||
indexes beyond it are declared yet: history and board queries (Features
|
||||
08/10/13) should justify their indexes as a later migration once their
|
||||
query shapes exist.
|
||||
|
||||
No ``ON DELETE`` clauses are declared on any foreign key in this
|
||||
module: children and definitions are deactivated, never deleted, so a
|
||||
deletion that would orphan instances or events is a bug and must fail
|
||||
loudly rather than cascade.
|
||||
|
||||
Completion events: ``completion_events`` is strictly append-only. That
|
||||
property is enforced at the DAO layer — the completion-events DAO
|
||||
exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT
|
||||
by database triggers, matching the module's no-trigger policy: a RAISE
|
||||
trigger would be the one exception to "pure CHECK, no triggers",
|
||||
complicate the later migration runner, and still prove nothing (any
|
||||
writer can run raw SQL). ``event_type`` is constrained to
|
||||
``completed``/``uncompleted``; a ``missed`` row is intentionally not
|
||||
representable — missed is a derived state announced on the HA event bus
|
||||
(Feature 10), never an appended event.
|
||||
|
||||
``child_id`` is deliberately denormalized (it is also derivable via
|
||||
``quest_instances``): history reporting (Feature 13) filters and rates
|
||||
per child directly against the log, and the log should stay
|
||||
self-describing without joins.
|
||||
|
||||
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
||||
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
||||
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
||||
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
|
||||
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
|
||||
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
|
||||
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
|
||||
Other actor sources are rejected; if an unattended system actor is
|
||||
ever needed, that is a schema migration, not a silent widening.
|
||||
|
||||
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
||||
``completed`` events — at completion time the due date is known and the
|
||||
flag is decidable. For ``uncompleted`` events the flag is optional:
|
||||
Feature 08 decides whether a reversal carries the reversed completion's
|
||||
on-time flag or NULL, and the DDL deliberately accepts both rather than
|
||||
guessing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: GLOB patterns matching a valid ``weekday_set`` CSV: one GLOB per
|
||||
#: possible element count 1..7 (a weekday set cannot exceed 7 elements).
|
||||
#: Each element is a single digit 0-6, separated by a literal comma.
|
||||
#: GLOB has no repetition quantifier, so the element count is enumerated.
|
||||
#: ``''`` and NULL are deliberately NOT matched: NULL is allowed for
|
||||
#: non-weekly rules via a separate ``IS NULL`` arm, and '' is invalid
|
||||
#: because weekly rules need at least one weekday.
|
||||
WEEKDAY_SET_GLOB_PATTERNS: list[str] = [
|
||||
"[0-6]",
|
||||
"[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
]
|
||||
|
||||
SCHEMA_V1_CHILDREN_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS children (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
display_name TEXT NOT NULL,
|
||||
colour TEXT,
|
||||
avatar_ref TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (typeof(sort_order) = 'integer'),
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_ADMIN_USERS_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
ha_user_id TEXT PRIMARY KEY NOT NULL,
|
||||
added_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
_WEEKDAY_SET_CHECK = " OR ".join(
|
||||
f"weekday_set GLOB '{pattern}'" for pattern in WEEKDAY_SET_GLOB_PATTERNS
|
||||
)
|
||||
|
||||
SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_type TEXT NOT NULL CHECK (rule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')),
|
||||
interval INTEGER NOT NULL DEFAULT 1 CHECK (typeof(interval) = 'integer' AND interval >= 1),
|
||||
weekday_set TEXT CHECK (weekday_set IS NULL OR ({_WEEKDAY_SET_CHECK})),
|
||||
day_of_month INTEGER CHECK (day_of_month IS NULL OR (typeof(day_of_month) = 'integer' AND day_of_month >= 1 AND day_of_month <= 31)),
|
||||
nth_weekday INTEGER CHECK (nth_weekday IS NULL OR (typeof(nth_weekday) = 'integer' AND nth_weekday IN (-1, 1, 2, 3, 4, 5))),
|
||||
month INTEGER CHECK (month IS NULL OR (typeof(month) = 'integer' AND month >= 1 AND month <= 12)),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT,
|
||||
CHECK (end_date IS NULL OR end_date >= start_date),
|
||||
CHECK (
|
||||
(rule_type = 'weekly' AND weekday_set IS NOT NULL)
|
||||
OR (rule_type = 'monthly' AND (day_of_month IS NOT NULL OR nth_weekday IS NOT NULL))
|
||||
OR (rule_type = 'yearly' AND month IS NOT NULL)
|
||||
OR (rule_type IN ('daily', 'custom') AND day_of_month IS NULL AND nth_weekday IS NULL AND month IS NULL)
|
||||
)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
#: Column body of ``quest_definitions``, shared between the v1 DDL
|
||||
#: below and the multi-assignee rebuild migration, which must recreate
|
||||
#: the table in exactly this shape (D-008: one definition, many
|
||||
#: assignees via ``quest_definition_assignees`` — no ``child_id``
|
||||
#: column on the definition itself).
|
||||
QUEST_DEFINITIONS_TABLE_SQL = """(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
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
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definitions {QUEST_DEFINITIONS_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definition_assignees (
|
||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
PRIMARY KEY (definition_id, child_id)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definition_windows (
|
||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
|
||||
due_time TEXT,
|
||||
PRIMARY KEY (definition_id, window)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS presence_schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
|
||||
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
|
||||
anchor_date TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL CHECK (
|
||||
NOT pattern GLOB '*[^0-6,|]*'
|
||||
AND NOT pattern GLOB '*[0-6][0-6]*'
|
||||
AND NOT pattern GLOB '*,,*'
|
||||
AND NOT pattern GLOB '*,|*'
|
||||
AND NOT pattern GLOB '*|,*'
|
||||
AND NOT pattern GLOB ',*'
|
||||
AND NOT pattern GLOB '*,'
|
||||
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
|
||||
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
|
||||
)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS presence_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)),
|
||||
note TEXT,
|
||||
CHECK (end_date >= start_date)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
#: Column body of ``quest_instances``, shared between the v1 DDL
|
||||
#: below and the instance-key rebuild migration, which must recreate
|
||||
#: the table in exactly this shape (D-008: the instance key is
|
||||
#: (definition_id, child_id, due_date, window) — one instance per
|
||||
#: assigned child per declared window per firing date).
|
||||
QUEST_INSTANCES_TABLE_SQL = """(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
|
||||
due_date TEXT NOT NULL,
|
||||
due_time TEXT,
|
||||
generated_at TEXT NOT NULL,
|
||||
UNIQUE (definition_id, child_id, due_date, window)
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
#: Column body of ``completion_events``, shared between the v1 DDL
|
||||
#: below and the actor-child rebuild migration, which must recreate the
|
||||
#: table in exactly this shape (D-008: ``actor_child_id`` records which
|
||||
#: panel profile was tapped — set for panel events, NULL for admin
|
||||
#: ones; legacy panel rows backfill to ``child_id``, the child they
|
||||
#: always were).
|
||||
COMPLETION_EVENTS_TABLE_SQL = """(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
||||
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
||||
actor_user_id TEXT,
|
||||
actor_child_id INTEGER REFERENCES children(id),
|
||||
occurred_at TEXT NOT NULL,
|
||||
was_on_time INTEGER CHECK (
|
||||
was_on_time IS NULL
|
||||
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
||||
),
|
||||
CHECK (
|
||||
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
||||
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
||||
),
|
||||
CHECK (
|
||||
(actor_source = 'user' AND actor_child_id IS NULL)
|
||||
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
|
||||
),
|
||||
CHECK (
|
||||
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
||||
OR (event_type = 'uncompleted')
|
||||
)
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
#: Generic integration-owned key/value state (schema version 7,
|
||||
#: Feature 11): persistent bookkeeping that belongs to the integration
|
||||
#: rather than any domain table — currently the missed-sweep
|
||||
#: watermark. ``value`` is a plain string; callers interpret it.
|
||||
META_STATE_TABLE_SQL = """(
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)"""
|
||||
|
||||
SCHEMA_V7_META_STATE_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS nestquest_meta_state {META_STATE_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_STATEMENTS: list[str] = [
|
||||
*SCHEMA_V1_CHILDREN_DDL,
|
||||
*SCHEMA_V1_ADMIN_USERS_DDL,
|
||||
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
*SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
||||
*SCHEMA_V7_META_STATE_DDL,
|
||||
]
|
||||
|
||||
|
||||
async def async_apply_ddl(
|
||||
database: NestQuestDatabase, statements: list[str]
|
||||
) -> None:
|
||||
"""Apply each DDL statement inside one transaction on ``database``.
|
||||
|
||||
Idempotent: every statement is ``CREATE TABLE IF NOT EXISTS``, so
|
||||
applying the same list twice leaves the schema and its rows intact.
|
||||
"""
|
||||
async with database.transaction():
|
||||
for sql in statements:
|
||||
await database.execute(sql)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Explicit admin-only versus open service registry (Feature 09).
|
||||
|
||||
One mapping, one file: every registered ``nestquest`` service name is
|
||||
either ``open`` or ``admin_only``. The permission decorator (later
|
||||
Feature 09 tasks) reads this table; it does not take per-handler
|
||||
policy arguments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .const import (
|
||||
DOMAIN_SERVICES,
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
SERVICE_MANAGE_CHILD,
|
||||
SERVICE_REGENERATE,
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
|
||||
OPEN = "open"
|
||||
ADMIN_ONLY = "admin_only"
|
||||
|
||||
SERVICE_POLICY: dict[str, str] = {
|
||||
SERVICE_COMPLETE_QUEST: OPEN,
|
||||
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
|
||||
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
|
||||
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
|
||||
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
|
||||
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
|
||||
SERVICE_REGENERATE: ADMIN_ONLY,
|
||||
}
|
||||
|
||||
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
|
||||
raise RuntimeError(
|
||||
"SERVICE_POLICY must name exactly the registered nestquest services, "
|
||||
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
|
||||
)
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Explicit, Home-Assistant-free settings object for NestQuest core.
|
||||
|
||||
The integration reads its configuration off a Home Assistant config
|
||||
entry (``entry.options``); the core package must not. This module
|
||||
bridges that split: it defines a frozen, validation-carrying
|
||||
:class:`NestQuestSettings` dataclass that the integration constructs
|
||||
from ``entry.options`` (via :meth:`NestQuestSettings.from_options`, or
|
||||
:meth:`NestQuestSettings.from_options_resilient` for the per-field
|
||||
fallback used at setup) and then passes into the core paths that
|
||||
previously read HA config themselves — the rolling horizon
|
||||
(``horizon_days``) and the daily rollover time (``day_rollover_time``),
|
||||
plus the Feature 11 notification settings (notify target, the three
|
||||
summary/reminder/report times, and the four independent enable toggles).
|
||||
|
||||
Nothing in this module imports ``homeassistant`` or touches a config
|
||||
entry; ``from_options`` accepts any plain mapping keyed by the
|
||||
:data:`~.const.CONF_*` names. The validation semantics match the
|
||||
options flow's: ``horizon_days`` must be a real integer >= 1 (bool
|
||||
rejected), and ``day_rollover_time`` plus the three notification times
|
||||
must be strict ``HH:MM`` 24-hour strings. Construction therefore can
|
||||
never yield a settings object whose horizon could shrink or explode the
|
||||
materialized window, or whose rollover fires at an unparsable time.
|
||||
|
||||
The strict resolvers (:func:`_resolve_horizon_days`, :func:`_validate_hhmm`,
|
||||
:func:`_resolve_bool`, :func:`_resolve_optional_str`) reject ``None``; the
|
||||
``None``-as-absent mapping lives ONLY in :meth:`from_options` /
|
||||
:meth:`from_options_resilient` (via :func:`_option_value`), so a hand-built
|
||||
:class:`NestQuestSettings` cannot smuggle a ``None`` past
|
||||
:meth:`__post_init__`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .const import (
|
||||
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,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
LOGGER,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
|
||||
|
||||
def _option_value(
|
||||
opts: Mapping[str, Any], key: str, default: Any
|
||||
) -> Any:
|
||||
"""Return ``opts[key]`` when present and non-None, else ``default``.
|
||||
|
||||
This is the SOLE place the ``None``-as-absent mapping lives: a stored
|
||||
``None`` is treated the same as an absent key — the historical
|
||||
``horizon_days`` path mapped a missing-or-None value to the default,
|
||||
and this keeps that forgiving semantics uniform across every field.
|
||||
The per-field resolvers (:func:`_resolve_horizon_days`,
|
||||
:func:`_validate_hhmm`, :func:`_resolve_bool`,
|
||||
:func:`_resolve_optional_str`) are STRICT — they reject ``None`` —
|
||||
so a partially-populated ``entry.options`` dict (or one migrated
|
||||
across versions) never raises just because a key carries ``None``,
|
||||
and a hand-built :class:`NestQuestSettings` cannot smuggle a
|
||||
``None`` past :meth:`NestQuestSettings.__post_init__`.
|
||||
"""
|
||||
if key not in opts:
|
||||
return default
|
||||
value = opts[key]
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def _validate_hhmm(value: Any, *, field: str) -> str:
|
||||
"""Validate a strict ``HH:MM`` string and return it; raise naming ``field``.
|
||||
|
||||
Uses the shared :data:`~.const.TIME_PATTERN` (the same compiled
|
||||
regex the options flow applies), so a stored option and a freshly
|
||||
validated form submit parse the same way. ``None`` (and any
|
||||
non-string) is rejected; the ``None``-as-absent mapping lives ONLY
|
||||
in :meth:`NestQuestSettings.from_options` (via :func:`_option_value`).
|
||||
"""
|
||||
if not isinstance(value, str) or TIME_PATTERN.fullmatch(value) is None:
|
||||
raise ValueError(
|
||||
f"{field} must be a strict HH:MM string, got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_horizon_days(value: Any) -> int:
|
||||
"""Validate a horizon value and return the resolved day count.
|
||||
|
||||
A bool is rejected (SQLite binds ``True``/``False`` onto an int), a
|
||||
non-int is rejected, and a sub-1 value is rejected — so a malformed
|
||||
option can never shrink or explode the materialized horizon.
|
||||
``None`` is rejected here too; the ``None``-as-absent mapping lives
|
||||
ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||
:func:`_option_value`), so a hand-built
|
||||
:class:`NestQuestSettings(horizon_days=None)` raises in
|
||||
:meth:`NestQuestSettings.__post_init__` rather than silently
|
||||
constructing an object whose :meth:`NestQuestSettings.horizon_window`
|
||||
would later raise ``TypeError`` on ``timedelta(days=None)``.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise ValueError(
|
||||
f"horizon_days must be a positive integer, got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_bool(value: Any) -> bool:
|
||||
"""Return a real bool; a non-bool (including ``None``) is rejected.
|
||||
|
||||
voluptuous ``bool`` would coerce strings, so the options flow uses a
|
||||
strict-bool validator and this mirrors it. The ``None``-as-absent
|
||||
mapping lives ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||
:func:`_option_value`).
|
||||
"""
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"expected a boolean, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_optional_str(value: Any) -> str:
|
||||
"""Return a stripped notify target string; a non-str is rejected.
|
||||
|
||||
A padded or whitespace-only value is STRIPPED to its trim — the
|
||||
shipped default is the empty string, meaning "no notifications are
|
||||
configured yet". This is intentionally MORE LENIENT than the options
|
||||
flow, which REJECTS a target that differs from its own trim (so a
|
||||
padded value never persists): the integration tolerates a stored
|
||||
padded value (e.g. one migrated from an older schema) by normalizing
|
||||
it rather than crashing setup. The ``None``-as-absent mapping lives
|
||||
ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||
:func:`_option_value`). :meth:`NestQuestSettings.__post_init__`
|
||||
applies this SAME normalizer so a hand-built
|
||||
``NestQuestSettings(notify_target=' x ')`` is stripped to ``'x'``
|
||||
exactly like the options-flow path (no padded value can slip through
|
||||
direct construction either).
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"notify_target must be a string, got {value!r}")
|
||||
return value.strip()
|
||||
|
||||
|
||||
#: The single field table the two builders share. Each row is
|
||||
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
|
||||
#: applies every validator (raising on the first invalid value) while
|
||||
#: ``from_options_resilient`` wraps each in its own try/except so a single
|
||||
#: bad sibling falls back to its default alone. Driving both builders
|
||||
#: from this table avoids three-way drift between ``__post_init__``'s
|
||||
#: inline checks and the two option builders' field lists: the only
|
||||
#: places that enumerate the fields are this table and
|
||||
#: ``__post_init__`` (which validates a hand-built instance without the
|
||||
#: None->default mapping, so it stays inline).
|
||||
_FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
|
||||
("horizon_days", CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, _resolve_horizon_days),
|
||||
(
|
||||
"day_rollover_time",
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
lambda v: _validate_hhmm(v, field="day_rollover_time"),
|
||||
),
|
||||
("notify_target", CONF_NOTIFY_TARGET, "", _resolve_optional_str),
|
||||
(
|
||||
"morning_summary_time",
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
lambda v: _validate_hhmm(v, field="morning_summary_time"),
|
||||
),
|
||||
(
|
||||
"afternoon_reminder_time",
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
lambda v: _validate_hhmm(v, field="afternoon_reminder_time"),
|
||||
),
|
||||
(
|
||||
"end_of_day_report_time",
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
lambda v: _validate_hhmm(v, field="end_of_day_report_time"),
|
||||
),
|
||||
(
|
||||
"morning_summary_enabled",
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
(
|
||||
"afternoon_reminder_enabled",
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
(
|
||||
"end_of_day_report_enabled",
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
(
|
||||
"celebration_enabled",
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NestQuestSettings:
|
||||
"""Explicit, HA-free configuration carried into core paths.
|
||||
|
||||
The integration builds this once from ``entry.options`` (via
|
||||
:meth:`from_options`, or :meth:`from_options_resilient` for the
|
||||
per-field fallback used at setup) and threads it into the horizon
|
||||
materialization and the daily-rollover scheduling. Core never
|
||||
reads HA config itself; callers pass the resolved horizon and
|
||||
rollover time in.
|
||||
|
||||
All fields are validated at construction; the dataclass is frozen so
|
||||
a stale settings object cannot be mutated in place after the
|
||||
integration hands it off.
|
||||
"""
|
||||
|
||||
horizon_days: int = DEFAULT_HORIZON_DAYS
|
||||
day_rollover_time: str = DEFAULT_DAY_ROLLOVER_TIME
|
||||
notify_target: str = ""
|
||||
morning_summary_time: str = DEFAULT_MORNING_SUMMARY_TIME
|
||||
afternoon_reminder_time: str = DEFAULT_AFTERNOON_REMINDER_TIME
|
||||
end_of_day_report_time: str = DEFAULT_END_OF_DAY_REPORT_TIME
|
||||
morning_summary_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Validate STRICTLY at construction so a hand-built instance
|
||||
# cannot carry an invalid value: the same rules from_options
|
||||
# applies, but WITHOUT the None->default mapping (that lives
|
||||
# only in from_options via _option_value). A None horizon, time,
|
||||
# or toggle raises here rather than later blowing up
|
||||
# horizon_window or the rollover listener.
|
||||
_resolve_horizon_days(self.horizon_days)
|
||||
_validate_hhmm(self.day_rollover_time, field="day_rollover_time")
|
||||
# ``notify_target`` is normalised the SAME way as from_options
|
||||
# (via _resolve_optional_str): a non-str is rejected and a
|
||||
# padded/whitespace-only value is STRIPPED to its trim. This
|
||||
# keeps a hand-built ``NestQuestSettings(notify_target=' x ')``
|
||||
# behaving like the options flow (which strips on persist) so a
|
||||
# padded value cannot slip through direct construction either.
|
||||
# The dataclass is frozen, so the stripped value is written via
|
||||
# object.__setattr__.
|
||||
stripped_notify_target = _resolve_optional_str(self.notify_target)
|
||||
object.__setattr__(self, "notify_target", stripped_notify_target)
|
||||
_validate_hhmm(self.morning_summary_time, field="morning_summary_time")
|
||||
_validate_hhmm(
|
||||
self.afternoon_reminder_time, field="afternoon_reminder_time"
|
||||
)
|
||||
_validate_hhmm(
|
||||
self.end_of_day_report_time, field="end_of_day_report_time"
|
||||
)
|
||||
for name in (
|
||||
"morning_summary_enabled",
|
||||
"afternoon_reminder_enabled",
|
||||
"end_of_day_report_enabled",
|
||||
"celebration_enabled",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(
|
||||
f"{name} must be a boolean, got {value!r}"
|
||||
)
|
||||
|
||||
@property
|
||||
def day_rollover_hour_minute(self) -> tuple[int, int]:
|
||||
"""The (hour, minute) the daily rollover fires at."""
|
||||
# The string is validated at construction, so a plain partition
|
||||
# is safe and avoids re-running the regex on every access.
|
||||
hour_str, _, minute_str = self.day_rollover_time.partition(":")
|
||||
return int(hour_str), int(minute_str)
|
||||
|
||||
def horizon_window(
|
||||
self, today: datetime.date
|
||||
) -> tuple[datetime.date, datetime.date]:
|
||||
"""Return the rolling horizon window ``[today, today + horizon_days]``.
|
||||
|
||||
``today`` is REQUIRED (there is no host-clock default): the
|
||||
integration passes the HA-local ``hass.config.time_zone`` date so
|
||||
the horizon tracks the household's own day, and a silent
|
||||
host-clock fallback would be a wrong-day footgun (a test or a
|
||||
caller that forgets to pass ``today`` would otherwise materialize
|
||||
against the machine's calendar, not the household's). The
|
||||
returned window is closed/inclusive — the same shape
|
||||
:func:`~.materialize.materialize` walks.
|
||||
"""
|
||||
end = today + datetime.timedelta(days=self.horizon_days)
|
||||
return today, end
|
||||
|
||||
@classmethod
|
||||
def from_options(
|
||||
cls, options: Mapping[str, Any] | None = None
|
||||
) -> "NestQuestSettings":
|
||||
"""Build settings from a CONF_*-keyed mapping (e.g. ``entry.options``).
|
||||
|
||||
Reads the same keys the options flow persists, applying the
|
||||
matching defaults when a key is absent OR ``None`` (via
|
||||
:func:`_option_value`, the SOLE place the ``None``-as-absent
|
||||
mapping lives) and validating STRICTLY: ``horizon_days`` is a
|
||||
positive int (bool rejected), the four times are strict
|
||||
``HH:MM``, the four toggles are real booleans, and
|
||||
``notify_target`` is a string (stripped). A malformed value
|
||||
RAISES — the integration uses :meth:`from_options_resilient` at
|
||||
setup to fall back per field instead of crashing. A ``None``
|
||||
mapping yields the all-defaults settings — useful for tests and
|
||||
for a fresh install whose options dict is empty.
|
||||
|
||||
``options`` is consumed read-only via
|
||||
:class:`types.MappingProxyType`, so this method never mutates
|
||||
the caller's mapping.
|
||||
"""
|
||||
opts: Mapping[str, Any] = (
|
||||
MappingProxyType({}) if options is None else MappingProxyType(options)
|
||||
)
|
||||
return cls(
|
||||
**{
|
||||
field: validator(_option_value(opts, key, default))
|
||||
for field, key, default, validator in _FIELDS
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_options_resilient(
|
||||
cls, options: Mapping[str, Any] | None = None
|
||||
) -> "NestQuestSettings":
|
||||
"""Build settings, falling back PER FIELD on a malformed value.
|
||||
|
||||
Unlike :meth:`from_options` (which raises on the first invalid
|
||||
field), this resolves EACH field in its own try/except: a
|
||||
malformed value logs a :data:`~.const.LOGGER` warning naming the
|
||||
field and falls back to that field's default. The integration
|
||||
uses this at setup so a single bad sibling option (e.g. a
|
||||
migrated ``morning_summary_time`` of ``'25:00'``) cannot
|
||||
silently reset the user's ``horizon_days`` or
|
||||
``day_rollover_time`` — the two fields core actually consumes.
|
||||
|
||||
An absent or ``None`` key maps to the default via
|
||||
:func:`_option_value` WITHOUT a warning (that is the normal
|
||||
missing-key path); only a present, non-``None``, invalid value
|
||||
triggers a warning and a per-field fallback.
|
||||
"""
|
||||
opts: Mapping[str, Any] = (
|
||||
MappingProxyType({}) if options is None else MappingProxyType(options)
|
||||
)
|
||||
resolved: dict[str, Any] = {}
|
||||
for field, key, default, validator in _FIELDS:
|
||||
raw = _option_value(opts, key, default)
|
||||
try:
|
||||
resolved[field] = validator(raw)
|
||||
except ValueError as err:
|
||||
LOGGER.warning(
|
||||
"NestQuest option %s is invalid (%s); falling back "
|
||||
"to the default %r",
|
||||
field,
|
||||
err,
|
||||
default,
|
||||
)
|
||||
resolved[field] = default
|
||||
return cls(**resolved)
|
||||
@@ -0,0 +1,306 @@
|
||||
"""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),
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""SQLite storage path resolution for the NestQuest storage layer.
|
||||
|
||||
Home-Assistant-free: callers resolve the database file location from
|
||||
their own config (HA resolves it from ``hass.config.path``) and pass
|
||||
the full path here; this module ensures the parent directory exists
|
||||
off-thread via :func:`asyncio.to_thread` and returns the absolute path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _ensure_dir(path: Path) -> None:
|
||||
"""Create the given directory and any missing parents."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def async_get_db_path(db_path: Path | str) -> Path:
|
||||
"""Return the absolute path to the NestQuest SQLite database file.
|
||||
|
||||
``db_path`` is the full path to the database file (the integration
|
||||
resolves it from ``hass.config.path(SQLITE_DB_FILENAME)``); the
|
||||
parent directory is created off the event loop via
|
||||
:func:`asyncio.to_thread` so this module never blocks and never
|
||||
imports Home Assistant.
|
||||
"""
|
||||
path = Path(db_path).absolute()
|
||||
await asyncio.to_thread(_ensure_dir, path.parent)
|
||||
return path
|
||||
@@ -1,319 +1,12 @@
|
||||
"""Typed DAO layer for the children and admin_users tables.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package.
|
||||
|
||||
All SQL for ``children`` and ``admin_users`` lives in this module per
|
||||
the feature guardrail: callers get typed dataclasses back and never see
|
||||
raw rows or SQL. Every method is async and runs through
|
||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
||||
executor and the event loop never blocks.
|
||||
|
||||
Error contract: violations surface as the underlying
|
||||
:class:`sqlite3.IntegrityError` (duplicate, foreign-key, or CHECK
|
||||
failure) from the execute call; the DAO adds no wrapping exception
|
||||
type. A get that matches nothing returns ``None``; a mutation that
|
||||
matches nothing is reported by :class:`~.db.ExecutionResult.rowcount`,
|
||||
which each method returns where the caller may need it (reorder, and
|
||||
the admin remove/set paths).
|
||||
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
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChildRecord:
|
||||
"""One row of ``children``."""
|
||||
|
||||
id: int
|
||||
display_name: str
|
||||
colour: str | None
|
||||
avatar_ref: str | None
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdminUserRecord:
|
||||
"""One row of ``admin_users``."""
|
||||
|
||||
ha_user_id: str
|
||||
added_at: str
|
||||
|
||||
|
||||
def _child_from_row(row: tuple) -> ChildRecord:
|
||||
"""Build a :class:`ChildRecord` from a SELECT row."""
|
||||
return ChildRecord(
|
||||
id=row[0],
|
||||
display_name=row[1],
|
||||
colour=row[2],
|
||||
avatar_ref=row[3],
|
||||
sort_order=row[4],
|
||||
is_active=bool(row[5]),
|
||||
created_at=row[6],
|
||||
)
|
||||
|
||||
|
||||
_CHILD_COLUMNS = (
|
||||
"id, display_name, colour, avatar_ref, sort_order, is_active, created_at"
|
||||
)
|
||||
|
||||
|
||||
#: One asyncio.Lock per (connection wrapper, running loop), shared by
|
||||
#: every ChildrenDao instance over the same database: the wrapper
|
||||
#: rejects a second concurrent transaction with RuntimeError, so ALL
|
||||
#: transactional DAO operations on one connection must queue here, not
|
||||
#: per-instance. Keyed like the migration runner's lock; entries are
|
||||
#: never evicted (a config entry holds one wrapper per process).
|
||||
_CONNECTION_LOCKS: dict[tuple[int, int], asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _connection_lock(database) -> asyncio.Lock:
|
||||
"""Return the shared transaction-serializing lock for ``database``."""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _CONNECTION_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_CONNECTION_LOCKS[key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
class ChildrenDao:
|
||||
"""Typed async data access for the ``children`` table.
|
||||
|
||||
Deletion is deliberately absent: children are deactivated, never
|
||||
deleted, because completion history references them (feature
|
||||
guardrail).
|
||||
"""
|
||||
|
||||
def __init__(self, database) -> None:
|
||||
self._database = database
|
||||
|
||||
async def create(
|
||||
self,
|
||||
display_name: str,
|
||||
created_at: str,
|
||||
*,
|
||||
colour: str | None = None,
|
||||
avatar_ref: str | None = None,
|
||||
sort_order: int = 0,
|
||||
is_active: bool = True,
|
||||
) -> ChildRecord:
|
||||
"""Insert one child and return the record as stored."""
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO children (display_name, colour, avatar_ref, "
|
||||
"sort_order, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
display_name,
|
||||
colour,
|
||||
avatar_ref,
|
||||
sort_order,
|
||||
int(is_active),
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
child = await self.get(result.lastrowid)
|
||||
assert child is not None
|
||||
return child
|
||||
|
||||
async def get(self, child_id: int) -> ChildRecord | None:
|
||||
"""Return the child with ``child_id``, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_CHILD_COLUMNS} FROM children WHERE id = ?",
|
||||
(child_id,),
|
||||
)
|
||||
return _child_from_row(row) if row is not None else None
|
||||
|
||||
async def list_active(self) -> list[ChildRecord]:
|
||||
"""Return active children ordered by sort_order, then id."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_CHILD_COLUMNS} FROM children WHERE is_active = 1 "
|
||||
"ORDER BY sort_order, id"
|
||||
)
|
||||
return [_child_from_row(row) for row in rows]
|
||||
|
||||
async def list_all(self) -> list[ChildRecord]:
|
||||
"""Return all children ordered by sort_order, then id."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_CHILD_COLUMNS} FROM children "
|
||||
"ORDER BY sort_order, id"
|
||||
)
|
||||
return [record for record in map(_child_from_row, rows)]
|
||||
|
||||
async def update(
|
||||
self,
|
||||
child_id: int,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
colour: str | None = None,
|
||||
avatar_ref: str | None = None,
|
||||
sort_order: int | None = None,
|
||||
) -> int:
|
||||
"""Update the given fields; returns rows updated (0 if absent).
|
||||
|
||||
Only non-None arguments are written, so a caller cannot
|
||||
accidentally blank a column by omitting it.
|
||||
"""
|
||||
assignments: list[str] = []
|
||||
parameters: list[object] = []
|
||||
if display_name is not None:
|
||||
assignments.append("display_name = ?")
|
||||
parameters.append(display_name)
|
||||
if colour is not None:
|
||||
assignments.append("colour = ?")
|
||||
parameters.append(colour)
|
||||
if avatar_ref is not None:
|
||||
assignments.append("avatar_ref = ?")
|
||||
parameters.append(avatar_ref)
|
||||
if sort_order is not None:
|
||||
assignments.append("sort_order = ?")
|
||||
parameters.append(sort_order)
|
||||
if not assignments:
|
||||
return 0
|
||||
parameters.append(child_id)
|
||||
result = await self._database.execute(
|
||||
f"UPDATE children SET {', '.join(assignments)} WHERE id = ?",
|
||||
tuple(parameters),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def set_active(self, child_id: int, is_active: bool) -> int:
|
||||
"""Set the child's active flag; returns rows updated (0 if absent)."""
|
||||
result = await self._database.execute(
|
||||
"UPDATE children SET is_active = ? WHERE id = ?",
|
||||
(int(is_active), child_id),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def reorder(self, ordered_ids: list[int]) -> None:
|
||||
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
|
||||
|
||||
The list must be a COMPLETE permutation of the children table:
|
||||
every existing child id appears exactly once. Partial lists
|
||||
(missing an existing child) and unknown ids (naming a child
|
||||
that does not exist) are rejected with ValueError BEFORE any
|
||||
write, leaving the current order untouched — a silent partial
|
||||
reorder would leave unlisted children with stale positions and
|
||||
hide a caller bug. Duplicate ids are rejected up front: they
|
||||
cannot all hold distinct positions, and silently assigning the
|
||||
last occurrence's index would hide a caller bug.
|
||||
|
||||
Runs inside one transaction; positions are staged at offset
|
||||
-len (all negative, so any permutation of the same ids never
|
||||
collides mid-reorder under the wrapper's single-connection
|
||||
serialization), then finalized 0..n-1.
|
||||
|
||||
Concurrent reorder() calls on one connection are serialized by
|
||||
a connection-scoped asyncio lock (shared across every ChildrenDao
|
||||
over the same wrapper, however many instances): the wrapper's
|
||||
transaction() rejects a second concurrent transaction with
|
||||
RuntimeError, so racing callers would crash instead of
|
||||
queueing. The lock makes the second call wait, then run
|
||||
against the first call's final state — which also keeps the
|
||||
completeness check honest: the re-read of the table happens
|
||||
inside the same lock and transaction as the writes.
|
||||
"""
|
||||
if len(set(ordered_ids)) != len(ordered_ids):
|
||||
raise ValueError(
|
||||
"reorder() received duplicate child ids; every id must "
|
||||
"appear at most once"
|
||||
)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT id FROM children"
|
||||
)
|
||||
existing = {row[0] for row in rows}
|
||||
listed = set(ordered_ids)
|
||||
missing = sorted(existing - listed)
|
||||
unknown = sorted(listed - existing)
|
||||
if missing or unknown:
|
||||
details = []
|
||||
if missing:
|
||||
details.append(
|
||||
f"missing child ids {missing} (a partial "
|
||||
"reorder would leave them with stale "
|
||||
"positions)"
|
||||
)
|
||||
if unknown:
|
||||
details.append(
|
||||
f"unknown child ids {unknown}"
|
||||
)
|
||||
raise ValueError(
|
||||
"reorder() must list every child exactly once; "
|
||||
+ "; ".join(details)
|
||||
)
|
||||
offset = len(ordered_ids)
|
||||
await self._database.execute_many(
|
||||
"UPDATE children SET sort_order = ? WHERE id = ?",
|
||||
[
|
||||
(-(offset + index), child_id)
|
||||
for index, child_id in enumerate(ordered_ids)
|
||||
],
|
||||
)
|
||||
await self._database.execute_many(
|
||||
"UPDATE children SET sort_order = ? WHERE id = ?",
|
||||
[
|
||||
(index, child_id)
|
||||
for index, child_id in enumerate(ordered_ids)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class AdminUsersDao:
|
||||
"""Typed async access to the ``admin_users`` allowlist.
|
||||
|
||||
The allowlist stores HA user IDs, never usernames (feature
|
||||
guardrail); an empty allowlist means no admin, enforced by callers
|
||||
resolving through :meth:`exists`.
|
||||
"""
|
||||
|
||||
def __init__(self, database) -> None:
|
||||
self._database = database
|
||||
|
||||
async def add(self, ha_user_id: str, added_at: str) -> bool:
|
||||
"""Add a user to the allowlist; True if inserted, False if present.
|
||||
|
||||
Idempotent under concurrency: a single INSERT ... ON CONFLICT DO
|
||||
NOTHING decides and inserts in one statement (the wrapper
|
||||
serializes statements, not check-then-insert sequences, so a
|
||||
SELECT/INSERT pair would let two concurrent adds both see no
|
||||
row and the second one raise IntegrityError). rowcount reports
|
||||
whether the insert actually happened.
|
||||
"""
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO admin_users (ha_user_id, added_at) VALUES (?, ?) "
|
||||
"ON CONFLICT (ha_user_id) DO NOTHING",
|
||||
(ha_user_id, added_at),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def remove(self, ha_user_id: str) -> bool:
|
||||
"""Remove a user from the allowlist; True if a row was removed."""
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM admin_users WHERE ha_user_id = ?",
|
||||
(ha_user_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def list(self) -> list[AdminUserRecord]:
|
||||
"""Return every allowlisted user, oldest first."""
|
||||
rows = await self._database.fetch_all(
|
||||
"SELECT ha_user_id, added_at FROM admin_users "
|
||||
"ORDER BY added_at, ha_user_id"
|
||||
)
|
||||
return [
|
||||
AdminUserRecord(ha_user_id=row[0], added_at=row[1])
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def exists(self, ha_user_id: str) -> bool:
|
||||
"""Return True if ``ha_user_id`` is allowlisted."""
|
||||
row = await self._database.fetch_one(
|
||||
"SELECT 1 FROM admin_users WHERE ha_user_id = ?",
|
||||
(ha_user_id,),
|
||||
)
|
||||
return row is not None
|
||||
from .core.dao_children import * # noqa: F401,F403
|
||||
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
|
||||
|
||||
@@ -1,590 +1,12 @@
|
||||
"""Typed DAO layer for quest_instances and the append-only completion_events.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package.
|
||||
|
||||
All SQL for these tables lives in this module per the feature
|
||||
guardrails: callers get typed dataclasses back and never see raw rows
|
||||
or SQL. Every method is async and runs through
|
||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
||||
executor and the event loop never blocks.
|
||||
|
||||
Append-only policy (Feature 08, D-005): ``completion_events`` is
|
||||
strictly append-only. This module is where that property is enforced
|
||||
mechanically — the completion-events DAO below exposes NO update or
|
||||
delete method at all; completions append, un-completions append a
|
||||
separate reversal event, and instance state derives from the latest
|
||||
event. A package-wide guard test (bottom of the test module) keeps
|
||||
mutation SQL for this table out of every other module.
|
||||
|
||||
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
|
||||
(definition_id, child_id, due_date, window) — re-running
|
||||
materialization for a tuple whose instance already exists refreshes
|
||||
only the generation-time snapshot columns and never duplicates a row.
|
||||
The schema's UNIQUE(definition_id, child_id, due_date, window) is the
|
||||
last line of defense. The upsert refuses a child that is not an
|
||||
assignee of the definition and a window outside ``const.QUEST_WINDOWS``
|
||||
(D-008 multi-assignee, multi-window model).
|
||||
|
||||
``delete_future_uncompleted`` implements the Feature 06/07 rule that
|
||||
reassignment and schedule edits regenerate future instances: it
|
||||
removes only instances at or after the cutoff that have NO completion
|
||||
event, inside one transaction, so an instance completed mid-call can
|
||||
never be deleted. Instances in the past are never touched (Feature 07
|
||||
guardrail: never delete history), and no method here deletes a single
|
||||
instance that has a completion event.
|
||||
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
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .dao_rules import _validate_date, _validate_window
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: Timestamp policy for completion_events.occurred_at: strict UTC
|
||||
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
|
||||
#: One accepted shape bounds lexicographic range queries correctly, so
|
||||
#: the occurred-at scope does not guess at timestamp spellings.
|
||||
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
||||
|
||||
|
||||
def _validate_utc_timestamp(value: str, field: str) -> None:
|
||||
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
|
||||
|
||||
Same strictness policy as _validate_date: parse with the exact
|
||||
format, then round-trip the parsed value against the input so
|
||||
non-padded or re-ordered shapes are rejected. The round-trip
|
||||
strips the UTC offset (isoformat() omits it when the offset is
|
||||
zero), so the suffix is appended before comparing.
|
||||
"""
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(
|
||||
value, _UTC_TIMESTAMP_FORMAT
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
f"{field} must be a UTC ISO-8601 timestamp "
|
||||
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() + "+00:00" != value:
|
||||
raise ValueError(
|
||||
f"{field} must be a strict UTC timestamp "
|
||||
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _next_day_start(date_str: str) -> str:
|
||||
"""Return the UTC timestamp one second past ``date_str``'s last second.
|
||||
|
||||
Gives range queries an exact half-open upper bound without
|
||||
fractional-second guessing.
|
||||
"""
|
||||
day = datetime.date.fromisoformat(date_str)
|
||||
next_day = day + datetime.timedelta(days=1)
|
||||
return f"{next_day.isoformat()}T00:00:00+00:00"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestInstanceRecord:
|
||||
"""One row of ``quest_instances``."""
|
||||
|
||||
id: int
|
||||
definition_id: int
|
||||
child_id: int
|
||||
window: str
|
||||
due_date: str
|
||||
due_time: str | None
|
||||
generated_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionEventRecord:
|
||||
"""One row of ``completion_events`` (append-only table)."""
|
||||
|
||||
id: int
|
||||
instance_id: int
|
||||
child_id: int
|
||||
event_type: str
|
||||
actor_source: str
|
||||
actor_user_id: str | None
|
||||
actor_child_id: int | None
|
||||
occurred_at: str
|
||||
was_on_time: bool | None
|
||||
|
||||
|
||||
_INSTANCE_COLUMNS = (
|
||||
"id, definition_id, child_id, window, due_date, due_time, generated_at"
|
||||
)
|
||||
_EVENT_COLUMNS = (
|
||||
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
|
||||
"actor_child_id, occurred_at, was_on_time"
|
||||
)
|
||||
|
||||
#: The only event types the append path accepts; the schema CHECK is
|
||||
#: the backstop, this is the DAO's typed front door.
|
||||
EVENT_COMPLETED = "completed"
|
||||
EVENT_UNCOMPLETED = "uncompleted"
|
||||
|
||||
|
||||
def _instance_from_row(row: tuple) -> QuestInstanceRecord:
|
||||
return QuestInstanceRecord(
|
||||
id=row[0],
|
||||
definition_id=row[1],
|
||||
child_id=row[2],
|
||||
window=row[3],
|
||||
due_date=row[4],
|
||||
due_time=row[5],
|
||||
generated_at=row[6],
|
||||
)
|
||||
|
||||
|
||||
def _event_from_row(row: tuple) -> CompletionEventRecord:
|
||||
return CompletionEventRecord(
|
||||
id=row[0],
|
||||
instance_id=row[1],
|
||||
child_id=row[2],
|
||||
event_type=row[3],
|
||||
actor_source=row[4],
|
||||
actor_user_id=row[5],
|
||||
actor_child_id=row[6],
|
||||
occurred_at=row[7],
|
||||
was_on_time=None if row[8] is None else bool(row[8]),
|
||||
)
|
||||
|
||||
|
||||
class QuestInstancesDao:
|
||||
"""Typed async access to the ``quest_instances`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
due_date: str,
|
||||
generated_at: str,
|
||||
*,
|
||||
window: str,
|
||||
due_time: str | None = None,
|
||||
) -> QuestInstanceRecord:
|
||||
"""Create the dated instance for the widened key idempotently.
|
||||
|
||||
The instance key is (definition_id, child_id, due_date, window)
|
||||
(D-008): running materialization twice for the same tuple
|
||||
yields ONE row — the ON CONFLICT path refreshes the
|
||||
generation-time snapshot columns (due_time) and regenerates the
|
||||
stamp, but can never create a duplicate. The schema's
|
||||
UNIQUE(definition_id, child_id, due_date, window) backs this
|
||||
up, so the twice-daily case (two windows, one child, one date)
|
||||
yields two rows and the shared case (three assignees, one
|
||||
window) yields three.
|
||||
|
||||
Guardrails enforced here:
|
||||
|
||||
- ``due_date`` must be TODAY or later: instances are never
|
||||
generated in the past (Feature 07). The caller's
|
||||
materialization horizon is future-dated by definition.
|
||||
- ``window`` must be one of ``const.QUEST_WINDOWS``.
|
||||
- An existing instance WITH a completion event is IMMUTABLE:
|
||||
the conflict path must not rewrite its snapshot columns
|
||||
(Feature 07/08). The upsert refuses with ValueError in that
|
||||
case — silently no-oping would mask a materialization bug.
|
||||
- The child must be an assignee of the definition (D-008
|
||||
multi-assignee model).
|
||||
|
||||
The definition must exist (validated atomically under the
|
||||
connection lock so a concurrent definition deactivation or
|
||||
child deletion cannot slip between check and insert).
|
||||
"""
|
||||
_validate_date(due_date, "due_date")
|
||||
_validate_window(window)
|
||||
# Fail fast on obviously-past dates BEFORE queuing on the
|
||||
# connection lock (a nice early error); the AUTHORITATIVE
|
||||
# check runs after the lock is acquired, because a caller can
|
||||
# wait on the lock across midnight — a date that was "today"
|
||||
# at call time may be "yesterday" by the time the INSERT runs.
|
||||
today = datetime.date.today().isoformat()
|
||||
if due_date < today:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: due_date "
|
||||
f"{due_date!r} is before today {today!r}"
|
||||
)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# Re-read "today" under the lock: this is the date the
|
||||
# insert actually executes on, so the no-past rule
|
||||
# holds across midnight rollovers.
|
||||
today = datetime.date.today().isoformat()
|
||||
if due_date < today:
|
||||
raise ValueError(
|
||||
f"instances are never generated in the past: "
|
||||
f"due_date {due_date!r} is before today "
|
||||
f"{today!r}"
|
||||
)
|
||||
definition = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definitions WHERE id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
if definition is None:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} does not exist"
|
||||
)
|
||||
assignee = await self._database.fetch_one(
|
||||
"SELECT 1 FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ? AND child_id = ?",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
if assignee is None:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} is not "
|
||||
f"assigned to child {child_id}; instance must "
|
||||
"carry an assigned child"
|
||||
)
|
||||
existing = await self._database.fetch_one(
|
||||
"SELECT 1 FROM completion_events "
|
||||
"WHERE instance_id = (SELECT id FROM quest_instances "
|
||||
"WHERE definition_id = ? AND child_id = ? "
|
||||
"AND due_date = ? AND window = ?)",
|
||||
(definition_id, child_id, due_date, window),
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError(
|
||||
f"instance for (definition {definition_id}, "
|
||||
f"child {child_id}, due_date {due_date!r}, "
|
||||
f"window {window!r}) already has a completion "
|
||||
"event and is immutable; it cannot be regenerated"
|
||||
)
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_instances (definition_id, child_id, "
|
||||
"window, due_date, due_time, generated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (definition_id, child_id, due_date, "
|
||||
"window) DO UPDATE SET "
|
||||
"due_time = excluded.due_time, "
|
||||
"generated_at = excluded.generated_at",
|
||||
(
|
||||
definition_id,
|
||||
child_id,
|
||||
window,
|
||||
due_date,
|
||||
due_time,
|
||||
generated_at,
|
||||
),
|
||||
)
|
||||
instance = await self.get(
|
||||
definition_id, child_id, due_date, window
|
||||
)
|
||||
assert instance is not None
|
||||
return instance
|
||||
|
||||
async def get(
|
||||
self,
|
||||
definition_id: int,
|
||||
child_id: int,
|
||||
due_date: str,
|
||||
window: str,
|
||||
) -> QuestInstanceRecord | None:
|
||||
"""Return the instance for the widened key tuple, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE definition_id = ? AND child_id = ? AND due_date = ? "
|
||||
"AND window = ?",
|
||||
(definition_id, child_id, due_date, window),
|
||||
)
|
||||
return _instance_from_row(row) if row is not None else None
|
||||
|
||||
async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None:
|
||||
"""Return the instance with ``instance_id``, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?",
|
||||
(instance_id,),
|
||||
)
|
||||
return _instance_from_row(row) if row is not None else None
|
||||
|
||||
async def list_by_child_and_date(
|
||||
self, child_id: int, due_date: str
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's instances due exactly on ``due_date``."""
|
||||
_validate_date(due_date, "due_date")
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE child_id = ? AND due_date = ? ORDER BY id",
|
||||
(child_id, due_date),
|
||||
)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def list_by_date_range(
|
||||
self,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
) -> list[QuestInstanceRecord]:
|
||||
"""Return the child's instances with due_date in the closed range."""
|
||||
_validate_date(range_start, "range_start")
|
||||
_validate_date(range_end, "range_end")
|
||||
if range_end < range_start:
|
||||
raise ValueError(
|
||||
"range_end must be on or after range_start, got "
|
||||
f"{range_end!r} < {range_start!r}"
|
||||
)
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
|
||||
"WHERE child_id = ? AND due_date >= ? AND due_date <= ? "
|
||||
"ORDER BY due_date, id",
|
||||
(child_id, range_start, range_end),
|
||||
)
|
||||
return [_instance_from_row(row) for row in rows]
|
||||
|
||||
async def delete_future_uncompleted(
|
||||
self, definition_id: int, cutoff_date: str
|
||||
) -> int:
|
||||
"""Delete the definition's open instances at/after cutoff.
|
||||
|
||||
Implements regeneration after a definition edit or reassignment
|
||||
(Features 06/07): only instances at or after the cutoff with NO
|
||||
completion event are removed, inside one transaction — an
|
||||
instance that gains a completion event mid-flight cannot be
|
||||
deleted. The cutoff cannot be backdated below today: "future"
|
||||
is measured against TODAY (materialization regenerates the
|
||||
rolling horizon from now), so a caller cannot use this to
|
||||
rewrite past open instances either — the day-rollover missed
|
||||
sweep (Feature 11) handles past-due instances instead. Any
|
||||
instance with a completion event is never touched, regardless
|
||||
of its date. Returns the number deleted.
|
||||
"""
|
||||
_validate_date(cutoff_date, "cutoff_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
# "today" is read under the lock: the DELETE actually
|
||||
# executes on this date, so a caller queued across
|
||||
# midnight cannot delete instances that became past
|
||||
# while it waited (backdating is clamped to the
|
||||
# execution date, not the call date).
|
||||
today = datetime.date.today().isoformat()
|
||||
effective_cutoff = max(cutoff_date, today)
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM quest_instances WHERE definition_id = ? "
|
||||
"AND due_date >= ? AND NOT EXISTS ("
|
||||
" SELECT 1 FROM completion_events "
|
||||
" WHERE instance_id = quest_instances.id"
|
||||
")",
|
||||
(definition_id, effective_cutoff),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
class CompletionEventsDao:
|
||||
"""Typed append-only access to the ``completion_events`` table.
|
||||
|
||||
APPEND-ONLY BY CONSTRUCTION: this class exposes ``append`` and the
|
||||
three read methods below, and NOTHING else. There is no update,
|
||||
no delete, no upsert — completions and un-completions both arrive
|
||||
through :meth:`append` as new rows, and history is immutable
|
||||
(Feature 08 done-condition: no code path updates or deletes an
|
||||
event). The package-wide leak-guard test additionally fails if
|
||||
mutation SQL for this table ever appears in any module.
|
||||
"""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def append(
|
||||
self,
|
||||
instance_id: int,
|
||||
child_id: int,
|
||||
event_type: str,
|
||||
actor_source: str,
|
||||
occurred_at: str,
|
||||
was_on_time: bool | None,
|
||||
*,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
) -> CompletionEventRecord:
|
||||
"""Append one completion or un-completion event.
|
||||
|
||||
``actor_source`` is 'user' (an admin action: ``actor_user_id``
|
||||
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
|
||||
id, and ``actor_child_id`` carrying the tapped child profile,
|
||||
D-008); the actor-pair CHECKs enforce this shape at the schema
|
||||
level. ``occurred_at`` must be the module's one strict UTC
|
||||
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
|
||||
queries stay exact. ``was_on_time`` is required (True/False)
|
||||
for completions; for un-completions it may be None or the
|
||||
reversed completion's flag (Feature 08 still deciding). The
|
||||
instance, child and — for panel events — the tapped child must
|
||||
exist — validated atomically under the connection lock.
|
||||
"""
|
||||
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||
raise ValueError(
|
||||
f"event_type must be '{EVENT_COMPLETED}' or "
|
||||
f"'{EVENT_UNCOMPLETED}', got {event_type!r}"
|
||||
)
|
||||
if actor_source not in ("user", "panel"):
|
||||
raise ValueError(
|
||||
f"actor_source must be 'user' or 'panel', got "
|
||||
f"{actor_source!r}"
|
||||
)
|
||||
if actor_source == "user" and not actor_user_id:
|
||||
raise ValueError(
|
||||
"actor_source 'user' requires actor_user_id"
|
||||
)
|
||||
if actor_source == "panel" and actor_user_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' must not carry actor_user_id"
|
||||
)
|
||||
if actor_source == "panel" and actor_child_id is None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' requires actor_child_id "
|
||||
"(the tapped child profile)"
|
||||
)
|
||||
if actor_source == "user" and actor_child_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'user' must not carry actor_child_id"
|
||||
)
|
||||
if was_on_time is not None and type(was_on_time) is not bool:
|
||||
raise ValueError(
|
||||
f"was_on_time must be True, False or None, got "
|
||||
f"{was_on_time!r}"
|
||||
)
|
||||
if event_type == EVENT_COMPLETED and was_on_time is None:
|
||||
raise ValueError(
|
||||
"a completed event requires was_on_time (True/False)"
|
||||
)
|
||||
_validate_utc_timestamp(occurred_at, "occurred_at")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
instance = await self._database.fetch_one(
|
||||
"SELECT child_id FROM quest_instances WHERE id = ?",
|
||||
(instance_id,),
|
||||
)
|
||||
if instance is None:
|
||||
raise ValueError(
|
||||
f"quest instance {instance_id} does not exist"
|
||||
)
|
||||
if instance[0] != child_id:
|
||||
raise ValueError(
|
||||
f"quest instance {instance_id} belongs to child "
|
||||
f"{instance[0]}, not {child_id}"
|
||||
)
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
if actor_child_id is not None:
|
||||
tapped = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?",
|
||||
(actor_child_id,),
|
||||
)
|
||||
if tapped is None:
|
||||
raise ValueError(
|
||||
f"actor_child_id {actor_child_id} does not "
|
||||
"reference an existing child"
|
||||
)
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO completion_events (instance_id, child_id, "
|
||||
"event_type, actor_source, actor_user_id, "
|
||||
"actor_child_id, occurred_at, was_on_time) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
instance_id,
|
||||
child_id,
|
||||
event_type,
|
||||
actor_source,
|
||||
actor_user_id,
|
||||
actor_child_id,
|
||||
occurred_at,
|
||||
None if was_on_time is None else int(was_on_time),
|
||||
),
|
||||
)
|
||||
event = await self._database.fetch_one(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE id = ?",
|
||||
(result.lastrowid,),
|
||||
)
|
||||
assert event is not None
|
||||
return _event_from_row(event)
|
||||
|
||||
async def list_by_instance(
|
||||
self, instance_id: int
|
||||
) -> list[CompletionEventRecord]:
|
||||
"""Return every event for the instance, in append order."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE instance_id = ? ORDER BY id",
|
||||
(instance_id,),
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
|
||||
async def list_by_child_and_date_range(
|
||||
self,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
*,
|
||||
due_date_scope: bool = True,
|
||||
) -> list[CompletionEventRecord]:
|
||||
"""Return the child's events for instances due in the closed range.
|
||||
|
||||
Scopes by the INSTANCE's due date (the day the task was owed),
|
||||
not the event's wall-clock timestamp: history reporting asks
|
||||
"what happened to the tasks due that day" (Feature 13). Set
|
||||
``due_date_scope=False`` to filter by ``occurred_at`` date
|
||||
instead. Ordered by instance, then event id.
|
||||
"""
|
||||
_validate_date(range_start, "range_start")
|
||||
_validate_date(range_end, "range_end")
|
||||
if range_end < range_start:
|
||||
raise ValueError(
|
||||
"range_end must be on or after range_start, got "
|
||||
f"{range_end!r} < {range_start!r}"
|
||||
)
|
||||
if due_date_scope:
|
||||
predicate = (
|
||||
"SELECT id FROM quest_instances WHERE child_id = ? "
|
||||
"AND due_date >= ? AND due_date <= ?"
|
||||
)
|
||||
parameters: list[object] = [child_id, range_start, range_end]
|
||||
else:
|
||||
# Occurred-at scope: occurred_at is validated to the one
|
||||
# accepted UTC shape at append time, so half-open day
|
||||
# bounds in that same shape are exact (no spelling drift).
|
||||
occurred_start = f"{range_start}T00:00:00+00:00"
|
||||
occurred_end = f"{range_end}T23:59:59+00:00"
|
||||
query = (
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE child_id = ? AND occurred_at >= ? "
|
||||
"AND occurred_at < ? ORDER BY instance_id, id"
|
||||
)
|
||||
rows = await self._database.fetch_all(
|
||||
query,
|
||||
[
|
||||
child_id,
|
||||
occurred_start,
|
||||
_next_day_start(range_end),
|
||||
],
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
f"WHERE instance_id IN ({predicate}) "
|
||||
"ORDER BY instance_id, id",
|
||||
tuple(parameters),
|
||||
)
|
||||
return [_event_from_row(row) for row in rows]
|
||||
|
||||
async def get_latest_for_instance(
|
||||
self, instance_id: int
|
||||
) -> CompletionEventRecord | None:
|
||||
"""Return the instance's most recent event, or None if untouched.
|
||||
|
||||
Instance current state derives from this event (Feature 08):
|
||||
'completed' means done, 'uncompleted' as the latest means open
|
||||
again, None means never completed.
|
||||
"""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
|
||||
"WHERE instance_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(instance_id,),
|
||||
)
|
||||
return _event_from_row(row) if row is not None else None
|
||||
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
|
||||
@@ -1,279 +1,11 @@
|
||||
"""Typed DAO layer for the presence_schedules and presence_overrides tables.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package.
|
||||
|
||||
All SQL for these tables lives in this module per the feature
|
||||
guardrails: callers get typed dataclasses back and never see raw rows
|
||||
or SQL. Every method is async and runs through
|
||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
||||
executor and the event loop never blocks.
|
||||
|
||||
Semantics per the done-condition and Feature 05:
|
||||
|
||||
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting
|
||||
by child updates the existing row rather than raising.
|
||||
- A child with NO schedule row is present every day — the absence of a
|
||||
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child`
|
||||
returning None, never by an empty pattern row.
|
||||
- Overrides beat the pattern for their date range (that resolution is
|
||||
the presence engine's job, Feature 05); the DAO only stores and
|
||||
lists them. A single-day override stores the same date as both
|
||||
start and end (schema CHECK requires end >= start).
|
||||
|
||||
Date arguments are validated as strict YYYY-MM-DD per the same policy
|
||||
the rules DAO applies: the schema's lexical CHECKs cannot prove
|
||||
calendar validity.
|
||||
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 dataclasses import dataclass
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .dao_rules import _UNSET, _validate_date
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PresenceScheduleRecord:
|
||||
"""One row of ``presence_schedules``."""
|
||||
|
||||
id: int
|
||||
child_id: int
|
||||
cycle_length_weeks: int
|
||||
anchor_date: str
|
||||
pattern: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PresenceOverrideRecord:
|
||||
"""One row of ``presence_overrides``."""
|
||||
|
||||
id: int
|
||||
child_id: int
|
||||
start_date: str
|
||||
end_date: str
|
||||
is_present: bool
|
||||
note: str | None
|
||||
|
||||
|
||||
_SCHEDULE_COLUMNS = (
|
||||
"id, child_id, cycle_length_weeks, anchor_date, pattern"
|
||||
)
|
||||
_OVERRIDE_COLUMNS = (
|
||||
"id, child_id, start_date, end_date, is_present, note"
|
||||
)
|
||||
|
||||
|
||||
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord:
|
||||
return PresenceScheduleRecord(
|
||||
id=row[0],
|
||||
child_id=row[1],
|
||||
cycle_length_weeks=row[2],
|
||||
anchor_date=row[3],
|
||||
pattern=row[4],
|
||||
)
|
||||
|
||||
|
||||
def _override_from_row(row: tuple) -> PresenceOverrideRecord:
|
||||
return PresenceOverrideRecord(
|
||||
id=row[0],
|
||||
child_id=row[1],
|
||||
start_date=row[2],
|
||||
end_date=row[3],
|
||||
is_present=bool(row[4]),
|
||||
note=row[5],
|
||||
)
|
||||
|
||||
|
||||
class PresenceSchedulesDao:
|
||||
"""Typed async access to the ``presence_schedules`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def upsert_by_child(
|
||||
self,
|
||||
child_id: int,
|
||||
cycle_length_weeks: int,
|
||||
anchor_date: str,
|
||||
pattern: str,
|
||||
) -> PresenceScheduleRecord:
|
||||
"""Set the child's presence schedule, replacing any existing one.
|
||||
|
||||
Upsert semantics are the done-condition: a second schedule for
|
||||
the same child updates the existing row rather than raising.
|
||||
Runs under the connection lock so a concurrent upsert/delete
|
||||
for the same child serializes instead of colliding on the
|
||||
schema's UNIQUE(child_id). Pattern validity (segment shape,
|
||||
segment count = cycle_length_weeks) is enforced by the schema
|
||||
CHECKs; the child must exist (FK) — validated atomically here
|
||||
so a deleted child cannot pass a stale check.
|
||||
"""
|
||||
_validate_date(anchor_date, "anchor_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
await self._database.execute(
|
||||
"INSERT INTO presence_schedules (child_id, "
|
||||
"cycle_length_weeks, anchor_date, pattern) "
|
||||
"VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT (child_id) DO UPDATE SET "
|
||||
"cycle_length_weeks = excluded.cycle_length_weeks, "
|
||||
"anchor_date = excluded.anchor_date, "
|
||||
"pattern = excluded.pattern",
|
||||
(child_id, cycle_length_weeks, anchor_date, pattern),
|
||||
)
|
||||
schedule = await self.get_by_child(child_id)
|
||||
assert schedule is not None
|
||||
return schedule
|
||||
|
||||
async def get_by_child(
|
||||
self, child_id: int
|
||||
) -> PresenceScheduleRecord | None:
|
||||
"""Return the child's schedule, or None when the child has none.
|
||||
|
||||
None is meaningful and UNAMBIGUOUS here: the child exists and
|
||||
has no schedule row, i.e. the child is present every day
|
||||
(Feature 05 guardrail). An unknown child id raises ValueError
|
||||
instead of returning None, because "nonexistent child" must
|
||||
never be silently read as "present every day".
|
||||
"""
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
|
||||
"WHERE child_id = ?",
|
||||
(child_id,),
|
||||
)
|
||||
return _schedule_from_row(row) if row is not None else None
|
||||
|
||||
async def delete(self, child_id: int) -> bool:
|
||||
"""Remove the child's schedule; True if a row was removed.
|
||||
|
||||
After deletion the child is present every day (no row means
|
||||
no restriction). Deleting a schedule the child never had
|
||||
returns False.
|
||||
"""
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM presence_schedules WHERE child_id = ?",
|
||||
(child_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
class PresenceOverridesDao:
|
||||
"""Typed async access to the ``presence_overrides`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def create(
|
||||
self,
|
||||
child_id: int,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
is_present: bool,
|
||||
*,
|
||||
note: str | None = None,
|
||||
) -> PresenceOverrideRecord:
|
||||
"""Insert one override and return the record as stored.
|
||||
|
||||
The child must exist (validated atomically under the
|
||||
connection lock). Date shape is validated strictly, and the
|
||||
schema CHECK rejects end < start.
|
||||
|
||||
Overlap policy (Feature 05): an override for a child may not
|
||||
overlap another override of the SAME child — two different
|
||||
present/absent answers for one date are unresolvable. The
|
||||
overlap check (inclusive boundaries) runs inside the same
|
||||
transaction as the INSERT, so a racing create cannot slip a
|
||||
conflicting override between the check and the write; a
|
||||
conflict raises ValueError naming the conflicting override's
|
||||
id and range.
|
||||
"""
|
||||
_validate_date(start_date, "start_date")
|
||||
_validate_date(end_date, "end_date")
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
# Only a well-ordered range has overlap semantics:
|
||||
# inverted ranges fall through to the INSERT and hit
|
||||
# the schema's end>=start CHECK, preserving the
|
||||
# IntegrityError contract the matrix tests rely on.
|
||||
if end_date >= start_date:
|
||||
conflicting = await self._database.fetch_one(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} "
|
||||
"FROM presence_overrides "
|
||||
"WHERE child_id = ? AND end_date >= ? "
|
||||
"AND start_date <= ? ORDER BY id LIMIT 1",
|
||||
(child_id, start_date, end_date),
|
||||
)
|
||||
else:
|
||||
conflicting = None
|
||||
if conflicting is not None:
|
||||
conflict = _override_from_row(conflicting)
|
||||
raise ValueError(
|
||||
f"override for child {child_id} "
|
||||
f"[{start_date}, {end_date}] overlaps existing "
|
||||
f"override {conflict.id} "
|
||||
f"[{conflict.start_date}, {conflict.end_date}] "
|
||||
f"(is_present={conflict.is_present})"
|
||||
)
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO presence_overrides (child_id, "
|
||||
"start_date, end_date, is_present, note) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(child_id, start_date, end_date, int(is_present), note),
|
||||
)
|
||||
override = await self._database.fetch_one(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
"WHERE id = ?",
|
||||
(result.lastrowid,),
|
||||
)
|
||||
assert override is not None
|
||||
return _override_from_row(override)
|
||||
|
||||
async def list_by_child_and_range(
|
||||
self,
|
||||
child_id: int,
|
||||
range_start: str,
|
||||
range_end: str,
|
||||
) -> list[PresenceOverrideRecord]:
|
||||
"""Return the child's overrides overlapping [range_start, range_end].
|
||||
|
||||
An override overlaps the range when it ends on or after the
|
||||
range starts and starts on or before the range ends (inclusive
|
||||
boundary dates, matching the storage model where overrides are
|
||||
closed date ranges). Ordered by start date.
|
||||
"""
|
||||
_validate_date(range_start, "range_start")
|
||||
_validate_date(range_end, "range_end")
|
||||
if range_end < range_start:
|
||||
raise ValueError(
|
||||
"range_end must be on or after range_start, got "
|
||||
f"{range_end!r} < {range_start!r}"
|
||||
)
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
|
||||
"WHERE child_id = ? AND end_date >= ? AND start_date <= ? "
|
||||
"ORDER BY start_date",
|
||||
(child_id, range_start, range_end),
|
||||
)
|
||||
return [_override_from_row(row) for row in rows]
|
||||
|
||||
async def delete(self, override_id: int) -> bool:
|
||||
"""Remove one override by id; True if a row was removed."""
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM presence_overrides WHERE id = ?",
|
||||
(override_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
from .core.dao_presence import * # noqa: F401,F403
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,422 +1,31 @@
|
||||
"""Async SQLite connection wrapper for the NestQuest integration."""
|
||||
"""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
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from collections.abc import AsyncIterator, Awaitable, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .core.db import ExecutionResult, NestQuestDatabase
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionResult:
|
||||
"""Plain-data outcome of one statement.
|
||||
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
|
||||
|
||||
``rowcount`` and ``lastrowid`` mirror the corresponding
|
||||
:class:`sqlite3.Cursor` attributes; both are captured on the executor
|
||||
thread so no sqlite3 object ever crosses back to the event loop.
|
||||
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.
|
||||
"""
|
||||
|
||||
rowcount: int
|
||||
lastrowid: int | None
|
||||
|
||||
|
||||
class NestQuestDatabase:
|
||||
"""Async wrapper around a single SQLite connection.
|
||||
|
||||
Every sqlite3 call is delegated to the Home Assistant executor through
|
||||
``hass.async_add_executor_job`` so the event loop is never blocked, and
|
||||
only plain Python data (row tuples and :class:`ExecutionResult`) is
|
||||
returned to the event loop.
|
||||
|
||||
Every operation is serialized through one asyncio.Lock per wrapper. A
|
||||
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
|
||||
interleaved coroutines cannot corrupt the transaction and concurrent
|
||||
BEGINs are impossible; statements issued while a transaction is open
|
||||
queue until it completes. Concurrent transactions on one wrapper are
|
||||
not supported: a second ``transaction()`` entered while one is active
|
||||
raises :class:`RuntimeError`. ``close()`` serializes on the same lock
|
||||
and is idempotent.
|
||||
|
||||
Every connection-affecting executor job (connect, pragmas, statements,
|
||||
transaction lifecycle and close) is awaited through the settlement
|
||||
helper :meth:`_await_settled`: if the awaiting task is cancelled while
|
||||
a job is in flight, the job still settles on the worker thread before
|
||||
the cancellation propagates, so the wrapper lock is never released
|
||||
while the executor thread is mid-job.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the wrapper around a Home Assistant instance."""
|
||||
self._hass = hass
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
self._rowcount: int = -1
|
||||
self._lastrowid: int | None = None
|
||||
self._lock: asyncio.Lock | None = None
|
||||
self._lock_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._in_transaction = False
|
||||
self._tx_task: asyncio.Task | None = None
|
||||
|
||||
def _get_lock(self) -> asyncio.Lock:
|
||||
"""Return the per-wrapper lock, (re)bound to the running loop.
|
||||
|
||||
HA drives every wrapper call from one event loop, so the lock is
|
||||
created once and reused; re-binding only when the running loop
|
||||
differs keeps the wrapper usable from plain synchronous drivers
|
||||
that run each call on a fresh loop.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
if self._lock is None or self._lock_loop is not loop:
|
||||
self._lock = asyncio.Lock()
|
||||
self._lock_loop = loop
|
||||
return self._lock
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Return True while the underlying connection is open."""
|
||||
return self._conn is not None
|
||||
|
||||
@property
|
||||
def rowcount(self) -> int:
|
||||
"""Row count of the most recent statement, or -1 when unset."""
|
||||
return self._rowcount
|
||||
|
||||
@property
|
||||
def lastrowid(self) -> int | None:
|
||||
"""Row id of the most recent insert, or None when unset."""
|
||||
return self._lastrowid
|
||||
|
||||
@asynccontextmanager
|
||||
async def _serialized(self) -> AsyncIterator[None]:
|
||||
"""Hold the wrapper lock unless the caller owns the active transaction.
|
||||
|
||||
A transaction holds the lock for its whole BEGIN..COMMIT span;
|
||||
statements issued by the transaction's own task are already
|
||||
serialized by it and must not re-acquire the non-reentrant lock.
|
||||
Any other task queues on the lock until the transaction ends.
|
||||
"""
|
||||
if (
|
||||
self._in_transaction
|
||||
and self._tx_task is not None
|
||||
and asyncio.current_task() is self._tx_task
|
||||
):
|
||||
yield
|
||||
return
|
||||
async with self._get_lock():
|
||||
yield
|
||||
|
||||
async def open(self, path: Path | str) -> NestQuestDatabase:
|
||||
"""Open the SQLite file with WAL journal mode and foreign keys on.
|
||||
|
||||
Cancellation-safe: if this call is cancelled while the connect job
|
||||
is in flight, the job still settles (see :meth:`_await_settled`).
|
||||
A connection the settled job created is never adopted by the
|
||||
cancelled call; it is closed through a settled executor job instead
|
||||
of being abandoned to garbage collection, and the cancellation
|
||||
propagates afterwards, so a follow-up ``open()`` starts from a
|
||||
clean state.
|
||||
"""
|
||||
if self._conn is not None:
|
||||
return self
|
||||
async with self._get_lock():
|
||||
if self._conn is not None:
|
||||
return self
|
||||
|
||||
def _open() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(
|
||||
path, check_same_thread=False, isolation_level=None
|
||||
)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
except BaseException:
|
||||
conn.close()
|
||||
raise
|
||||
return conn
|
||||
|
||||
settled, cancelled = await self._await_settled_state(
|
||||
self._hass.async_add_executor_job(_open)
|
||||
)
|
||||
if cancelled:
|
||||
if isinstance(settled, sqlite3.Connection):
|
||||
|
||||
def _close_abandoned() -> None:
|
||||
settled.close()
|
||||
|
||||
await self._await_settled(
|
||||
self._hass.async_add_executor_job(_close_abandoned)
|
||||
)
|
||||
raise asyncio.CancelledError()
|
||||
self._conn = settled
|
||||
return self
|
||||
|
||||
async def execute(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> ExecutionResult:
|
||||
"""Run one statement in the executor and return its plain result."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _execute() -> ExecutionResult:
|
||||
cursor = conn.execute(sql, parameters)
|
||||
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
||||
|
||||
result = await self._await_settled(
|
||||
self._hass.async_add_executor_job(_execute)
|
||||
)
|
||||
self._rowcount = result.rowcount
|
||||
self._lastrowid = result.lastrowid
|
||||
return result
|
||||
|
||||
async def execute_many(
|
||||
self, sql: str, parameters: Sequence[Sequence[Any]]
|
||||
) -> ExecutionResult:
|
||||
"""Run executemany in the executor and return its plain result."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _execute_many() -> ExecutionResult:
|
||||
cursor = conn.executemany(sql, parameters)
|
||||
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
|
||||
|
||||
result = await self._await_settled(
|
||||
self._hass.async_add_executor_job(_execute_many)
|
||||
)
|
||||
self._rowcount = result.rowcount
|
||||
self._lastrowid = result.lastrowid
|
||||
return result
|
||||
|
||||
async def fetch_one(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> tuple | None:
|
||||
"""Run a query in the executor and return its first row, or None."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _fetch_one() -> tuple[tuple | None, int, int | None]:
|
||||
cursor = conn.execute(sql, parameters)
|
||||
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
|
||||
|
||||
row, rowcount, lastrowid = await self._await_settled(
|
||||
self._hass.async_add_executor_job(_fetch_one)
|
||||
)
|
||||
self._rowcount = rowcount
|
||||
self._lastrowid = lastrowid
|
||||
return row
|
||||
|
||||
async def fetch_all(
|
||||
self, sql: str, parameters: Sequence[Any] = ()
|
||||
) -> list[tuple]:
|
||||
"""Run a query in the executor and return all of its rows."""
|
||||
async with self._serialized():
|
||||
conn = self._require_conn()
|
||||
|
||||
def _fetch_all() -> tuple[list[tuple], int, int | None]:
|
||||
cursor = conn.execute(sql, parameters)
|
||||
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
|
||||
|
||||
rows, rowcount, lastrowid = await self._await_settled(
|
||||
self._hass.async_add_executor_job(_fetch_all)
|
||||
)
|
||||
self._rowcount = rowcount
|
||||
self._lastrowid = lastrowid
|
||||
return rows
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self) -> AsyncIterator[None]:
|
||||
"""Run a block inside BEGIN/COMMIT, rolling back on any exception.
|
||||
|
||||
The wrapper lock is held for the entire BEGIN..COMMIT span, so no
|
||||
other statement can interleave inside the transaction. Entering a
|
||||
second transaction while one is active raises RuntimeError. A
|
||||
failing COMMIT (e.g. a deferred foreign-key violation) also triggers
|
||||
a ROLLBACK before the commit error propagates, so the real
|
||||
connection is never left inside a stray transaction.
|
||||
|
||||
The lifecycle is cancellation-safe. Transaction ownership and the
|
||||
wrapper lock are only released after the executor work has fully
|
||||
settled, even when the owning task is cancelled mid-flight: if a
|
||||
cancelled BEGIN or COMMIT still completes on the worker thread,
|
||||
the corresponding ROLLBACK is shielded and awaited before any state
|
||||
is cleared. A lifecycle statement that fails plus a failing
|
||||
follow-up cleanup surface both errors in an ExceptionGroup.
|
||||
"""
|
||||
if self._in_transaction:
|
||||
raise RuntimeError(
|
||||
"A NestQuest transaction is already active on this "
|
||||
"connection; concurrent transactions are not supported"
|
||||
)
|
||||
async with self._get_lock():
|
||||
conn = self._require_conn()
|
||||
self._in_transaction = True
|
||||
self._tx_task = asyncio.current_task()
|
||||
try:
|
||||
|
||||
def _begin() -> None:
|
||||
conn.execute("BEGIN")
|
||||
|
||||
await self._settle_lifecycle(
|
||||
conn,
|
||||
self._hass.async_add_executor_job(_begin),
|
||||
"BEGIN did not settle cleanly and the follow-up "
|
||||
"cleanup also raised; the transaction may need manual "
|
||||
"cleanup",
|
||||
)
|
||||
try:
|
||||
yield
|
||||
except BaseException:
|
||||
|
||||
def _rollback() -> None:
|
||||
conn.execute("ROLLBACK")
|
||||
|
||||
await self._settle_lifecycle(
|
||||
conn,
|
||||
self._hass.async_add_executor_job(_rollback),
|
||||
"ROLLBACK failed and the follow-up cleanup also "
|
||||
"raised; the transaction may need manual cleanup",
|
||||
)
|
||||
raise
|
||||
|
||||
def _commit() -> None:
|
||||
conn.execute("COMMIT")
|
||||
|
||||
await self._settle_lifecycle(
|
||||
conn,
|
||||
self._hass.async_add_executor_job(_commit),
|
||||
"COMMIT failed and the follow-up cleanup also raised; "
|
||||
"the transaction may need manual cleanup",
|
||||
)
|
||||
finally:
|
||||
self._in_transaction = False
|
||||
self._tx_task = None
|
||||
|
||||
async def _settle_lifecycle(
|
||||
self, conn: sqlite3.Connection, job: Awaitable[Any], message: str
|
||||
) -> None:
|
||||
"""Run one BEGIN/ROLLBACK/COMMIT job, then force the connection clean.
|
||||
|
||||
The job always settles first (see :meth:`_await_settled`), so a
|
||||
cancelled statement that the worker thread completed anyway is
|
||||
never abandoned mid-flight. If the job raised, a follow-up
|
||||
ROLLBACK — itself settled — guarantees the connection ends outside
|
||||
any SQLite transaction before the error propagates. When both the
|
||||
job and the cleanup raise, both errors are preserved in an
|
||||
ExceptionGroup instead of the original being lost.
|
||||
"""
|
||||
try:
|
||||
await self._await_settled(job)
|
||||
except BaseException as settle_error:
|
||||
errors: list[BaseException] = [settle_error]
|
||||
try:
|
||||
await self._ensure_no_stray_transaction(conn)
|
||||
except BaseException as cleanup_error:
|
||||
errors.append(cleanup_error)
|
||||
if len(errors) == 2:
|
||||
raise BaseExceptionGroup(message, errors) from settle_error
|
||||
raise
|
||||
|
||||
async def _ensure_no_stray_transaction(
|
||||
self, conn: sqlite3.Connection
|
||||
) -> None:
|
||||
"""Force the connection out of any transaction via ROLLBACK.
|
||||
|
||||
Runs after a failed or cancelled lifecycle statement. The worker
|
||||
thread may still be finishing (or may already have completed) the
|
||||
very statement whose await was cancelled, so both the check and
|
||||
the ROLLBACK settle in the executor before the caller releases
|
||||
transaction ownership.
|
||||
"""
|
||||
|
||||
def _cleanup() -> bool:
|
||||
if conn.in_transaction:
|
||||
conn.execute("ROLLBACK")
|
||||
return conn.in_transaction
|
||||
|
||||
stray = await self._await_settled(
|
||||
self._hass.async_add_executor_job(_cleanup)
|
||||
)
|
||||
if stray:
|
||||
raise RuntimeError(
|
||||
"The SQLite connection is still inside a transaction after "
|
||||
"cleanup; the transaction may need manual cleanup"
|
||||
)
|
||||
|
||||
async def _await_settled(self, job: Awaitable[Any]) -> Any:
|
||||
"""Await ``job``, letting it settle even if this task is cancelled.
|
||||
|
||||
Cancellation is observed, not swallowed: it is re-raised only once
|
||||
the job has fully settled, so the caller can finalize against a
|
||||
known connection state instead of abandoning in-flight work. The
|
||||
job is wrapped in a task because a bare coroutine dies un-awaited
|
||||
once its await is cancelled. A second cancellation during
|
||||
settlement is absorbed the same way; if the job itself fails after
|
||||
a cancellation, both errors surface in an ExceptionGroup.
|
||||
"""
|
||||
result, cancelled = await self._await_settled_state(job)
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError()
|
||||
return result
|
||||
|
||||
async def _await_settled_state(self, job: Awaitable[Any]) -> tuple[Any, bool]:
|
||||
"""Await ``job`` to settlement and report ``(result, cancelled)``.
|
||||
|
||||
Same settlement guarantees as :meth:`_await_settled`, but returns
|
||||
the settled outcome together with whether the awaiting task was
|
||||
cancelled, so callers that must act on a settled result before
|
||||
re-raising cancellation (e.g. closing an abandoned connection)
|
||||
can do so without losing the result.
|
||||
"""
|
||||
cancelled = False
|
||||
inner = asyncio.ensure_future(job)
|
||||
while not inner.done():
|
||||
try:
|
||||
await asyncio.shield(inner)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
try:
|
||||
result = inner.result()
|
||||
except BaseException as job_error:
|
||||
if cancelled:
|
||||
raise BaseExceptionGroup(
|
||||
"executor job failed while settling after cancellation",
|
||||
[asyncio.CancelledError(), job_error],
|
||||
) from job_error
|
||||
raise
|
||||
return result, cancelled
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the connection in the executor; safe to call repeatedly.
|
||||
|
||||
Serialized on the wrapper lock, so an in-flight operation or open
|
||||
transaction always completes before the connection is closed.
|
||||
"""
|
||||
if (
|
||||
self._in_transaction
|
||||
and self._tx_task is not None
|
||||
and asyncio.current_task() is self._tx_task
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Cannot close the NestQuest database from inside its own "
|
||||
"transaction; exit the transaction first"
|
||||
)
|
||||
async with self._get_lock():
|
||||
conn, self._conn = self._conn, None
|
||||
self._rowcount, self._lastrowid = -1, None
|
||||
if conn is None:
|
||||
return
|
||||
|
||||
def _close() -> None:
|
||||
conn.close()
|
||||
|
||||
await self._await_settled(self._hass.async_add_executor_job(_close))
|
||||
|
||||
def _require_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError(
|
||||
"The NestQuest database connection is closed; call open() first"
|
||||
)
|
||||
return self._conn
|
||||
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)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Lovelace card bundle registration for NestQuest."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
WWW_DIR = Path(__file__).parent / "www"
|
||||
FRONTEND_URL_BASE = "/nestquest-static"
|
||||
BUNDLE_FILENAME = "nestquest-cards.js"
|
||||
BUNDLE_URL = f"{FRONTEND_URL_BASE}/{BUNDLE_FILENAME}"
|
||||
_REGISTERED_ATTR = "_nestquest_frontend_registered"
|
||||
|
||||
|
||||
async def async_register_frontend(hass: HomeAssistant) -> None:
|
||||
"""Serve the card bundle and inject it into every Lovelace page.
|
||||
|
||||
Idempotent across config entries: static paths and extra JS URLs are
|
||||
process-global, so a second entry must not register them again.
|
||||
"""
|
||||
if getattr(hass, _REGISTERED_ATTR, False):
|
||||
return
|
||||
await hass.http.async_register_static_paths(
|
||||
[
|
||||
StaticPathConfig(
|
||||
FRONTEND_URL_BASE,
|
||||
str(WWW_DIR),
|
||||
cache_headers=False,
|
||||
)
|
||||
]
|
||||
)
|
||||
add_extra_js_url(hass, BUNDLE_URL)
|
||||
setattr(hass, _REGISTERED_ATTR, True)
|
||||
@@ -5,10 +5,14 @@
|
||||
"@overseer"
|
||||
],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/talon2king/NestQuest",
|
||||
"dependencies": [
|
||||
"frontend",
|
||||
"http"
|
||||
],
|
||||
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/talon2king/NestQuest/issues",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.3.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
|
||||
@@ -1,584 +1,11 @@
|
||||
"""Versioned schema migration runner for the NestQuest SQLite database.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package.
|
||||
|
||||
The runner owns one metadata table, ``nestquest_schema_version``, holding
|
||||
a single row with the schema version the database file currently
|
||||
reflects. Migrations are declared as an ordered list: entry ``n``
|
||||
upgrades version ``n-1`` to version ``n``. Applying a migration means
|
||||
running its DDL inside one transaction and stamping the new version in
|
||||
that same transaction, so a database never persists a half-applied
|
||||
migration: any failure rolls the statements and the version stamp back
|
||||
together, leaving the file at its prior version.
|
||||
|
||||
Version 0 means "the file exists but carries no NestQuest schema yet"
|
||||
— a fresh file, or a file this integration has never written. Starting
|
||||
the runner against version 0 applies migration 1, which applies the
|
||||
full v1 DDL from :mod:`.schema` and stamps version 1.
|
||||
|
||||
Migration 1 was rewritten pre-release (D-007): nothing was ever
|
||||
shipped, so the v1 DDL now creates the ``quest_*`` tables directly
|
||||
instead of the legacy ``task_*`` spellings, and no compatibility shim
|
||||
exists. Dev machines that already hold a version-1 database with
|
||||
``task_*`` tables are handled by migration 2, which detects the legacy
|
||||
table names and ``ALTER TABLE ... RENAME TO``s them to their current
|
||||
names before anything else assumes the new model. Migration 3 brings
|
||||
pre-D-008 databases to the multi-assignee model: it creates
|
||||
``quest_definition_assignees`` and, when ``quest_definitions`` still
|
||||
carries a single ``child_id`` column, rebuilds the table without it,
|
||||
copying each definition's assignee across. Migration 4 adds the
|
||||
windows table, migration 5 rebuilds ``quest_instances`` onto the
|
||||
widened (definition_id, child_id, due_date, window) key, and migration
|
||||
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
|
||||
column. 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.
|
||||
|
||||
Migration entries are either an ordered sequence of SQL statements or
|
||||
an async callable taking ``(database, target_version)``. Callables
|
||||
exist for steps whose SQL depends on the database's current state (a
|
||||
legacy rename only fires when a legacy table is present; a rebuild only
|
||||
when the old column shape is present) and own their transaction so they
|
||||
can bracket it with connection pragmas the runner's uniform transaction
|
||||
cannot express.
|
||||
|
||||
Each migration's statements and its version stamp run inside ONE
|
||||
transaction, and the stamp is written as DELETE + INSERT rather than
|
||||
UPDATE because a version-0 database has no version row to update yet.
|
||||
An UPDATE form would silently match zero rows there and leave the
|
||||
database migrated but unstamped — reporting version 0 forever, and
|
||||
re-applying migration 1 on every startup. The DELETE+INSERT shape is
|
||||
also what makes the stamp a replace, so the row never duplicates.
|
||||
|
||||
Idempotence across restarts: a database already at the latest version
|
||||
has no pending migrations, so the runner opens a read-only check, finds
|
||||
nothing to do, and leaves the file byte-untouched (no write transaction
|
||||
is opened, so no WAL churn).
|
||||
|
||||
Migration list policy: entries are append-only. Once shipped, a
|
||||
migration's SQL must never be edited — an old database applying it for
|
||||
the first time and a fresh database would then diverge. Fix-forward
|
||||
with a new migration instead. The version stamp lives in the same
|
||||
SQLite file as the data it describes, so restoring a backup restores
|
||||
its matching schema version with it.
|
||||
|
||||
Corrupt or missing files are not this module's concern: startup
|
||||
handling of those cases is task d5bca74b, which decides whether to
|
||||
open fresh or raise ConfigEntryNotReady. This runner assumes it is
|
||||
handed an open, working connection.
|
||||
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
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Union
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
from .schema import (
|
||||
COMPLETION_EVENTS_TABLE_SQL,
|
||||
QUEST_DEFINITIONS_TABLE_SQL,
|
||||
QUEST_INSTANCES_TABLE_SQL,
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: One migration entry: either an ordered sequence of SQL statements or
|
||||
#: an async callable taking ``(database, target_version)`` that manages
|
||||
#: its own transaction and stamps via :func:`stamp_schema_version`.
|
||||
MigrationStep = Union[
|
||||
Sequence[str],
|
||||
Callable[[NestQuestDatabase, int], Awaitable[None]],
|
||||
]
|
||||
|
||||
#: Metadata table holding the applied schema version. Prefixed with the
|
||||
#: integration domain so a config directory shared with other tools can
|
||||
#: never collide with a foreign ``schema_version`` table.
|
||||
VERSION_TABLE = "nestquest_schema_version"
|
||||
|
||||
#: DDL for the version metadata table. The singleton-row invariant is
|
||||
#: enforced by the schema itself: ``id`` is fixed at 1 by CHECK, keyed
|
||||
#: PRIMARY KEY, so the table can never hold a second row, and the
|
||||
#: stamp write is an UPDATE (no DELETE+INSERT needed). ``version`` is
|
||||
#: a positive integer: version 0 means "no row" and is never stored,
|
||||
#: so a negative or zero stored value is corruption, not a state the
|
||||
#: runner can act on.
|
||||
VERSION_TABLE_DDL = f"""
|
||||
CREATE TABLE IF NOT EXISTS {VERSION_TABLE} (
|
||||
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
|
||||
version INTEGER NOT NULL CHECK (version >= 1)
|
||||
)
|
||||
"""
|
||||
|
||||
#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades
|
||||
#: version ``n-1`` to version ``n``.
|
||||
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
|
||||
|
||||
#: The assignees table DDL, shared with migration 1 via the schema
|
||||
#: module so a rebuilt and a freshly created table cannot drift.
|
||||
_ASSIGNEES_TABLE_DDL: str = SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL[0]
|
||||
|
||||
#: Legacy (pre-D-007) table name -> current table name. Order matters:
|
||||
#: the parent table renames first so the child's foreign key reference
|
||||
#: is rewritten while its parent's new name is already in place.
|
||||
LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = (
|
||||
("task_definitions", "quest_definitions"),
|
||||
("task_instances", "quest_instances"),
|
||||
)
|
||||
|
||||
|
||||
async def stamp_schema_version(
|
||||
database: NestQuestDatabase, version: int
|
||||
) -> None:
|
||||
"""Write the schema version stamp (inside the caller's transaction).
|
||||
|
||||
INSERT keyed on the singleton row (id = 1) with an upsert: a fresh
|
||||
file has no row yet, so UPDATE alone would silently match zero rows
|
||||
there and leave a migrated-but-unstamped database.
|
||||
"""
|
||||
await database.execute(
|
||||
f"INSERT INTO {VERSION_TABLE} (id, version) "
|
||||
"VALUES (1, ?) "
|
||||
"ON CONFLICT (id) DO UPDATE SET version = excluded.version",
|
||||
(version,),
|
||||
)
|
||||
|
||||
|
||||
async def _table_has_column(
|
||||
database: NestQuestDatabase, table: str, column: str
|
||||
) -> bool:
|
||||
"""Return True when ``table`` currently carries ``column``."""
|
||||
rows = await database.fetch_all(f"PRAGMA table_info({table})")
|
||||
return any(row[1] == column for row in rows)
|
||||
|
||||
|
||||
#: Window assigned to instances migrated from the pre-window instance
|
||||
#: shape (D-008). Pre-window rows were generated for whole-day
|
||||
#: obligations; the morning window is the documented default landing
|
||||
#: spot, and future dates are regenerated with real window declarations
|
||||
#: by materialization (delete_future_uncompleted + re-run).
|
||||
LEGACY_MIGRATED_WINDOW = "morning"
|
||||
|
||||
|
||||
async def _rename_legacy_task_tables(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Rename any surviving ``task_*`` tables to their ``quest_*`` names.
|
||||
|
||||
Dev-machines-only path (D-007): a database stamped version 1 by the
|
||||
pre-rename runner holds ``task_definitions``/``task_instances``.
|
||||
Each legacy name still present is renamed; fresh databases find
|
||||
nothing and this step is a no-op. Modern SQLite rewrites foreign
|
||||
key clauses in other tables to follow the rename, so the renamed
|
||||
schema is indistinguishable from a freshly created one apart from
|
||||
internal autoindex names. Runs in its own transaction together
|
||||
with the version stamp, so a crash rolls both back.
|
||||
"""
|
||||
async with database.transaction():
|
||||
for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES:
|
||||
row = await database.fetch_one(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name = ?",
|
||||
(legacy_name,),
|
||||
)
|
||||
if row is not None:
|
||||
await database.execute(
|
||||
f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"'
|
||||
)
|
||||
LOGGER.info(
|
||||
"Renamed legacy table %s to %s",
|
||||
legacy_name,
|
||||
current_name,
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
|
||||
|
||||
async def _add_definition_assignees(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Bring definitions to the multi-assignee model (D-008).
|
||||
|
||||
Two shapes arrive here:
|
||||
|
||||
- Fresh or renamed databases whose ``quest_definitions`` already
|
||||
matches the current canonical DDL (no ``child_id`` column): only
|
||||
the assignees table needs creating (``IF NOT EXISTS`` is a no-op
|
||||
when migration 1 already made it).
|
||||
- Databases stamped at version 2 whose ``quest_definitions`` still
|
||||
carries the single ``child_id`` column: the table is rebuilt via
|
||||
SQLite's canonical procedure — create the new table, copy rows,
|
||||
move each definition's assignee into
|
||||
``quest_definition_assignees``, drop the old table, rename — so
|
||||
``DROP COLUMN``'s foreign-key restriction never applies and no
|
||||
definition or instance row is lost.
|
||||
|
||||
The copy runs with ``foreign_keys = OFF`` because dropping the old
|
||||
table under enforcement would fire the implicit DELETE and violate
|
||||
the ``quest_instances`` foreign key; ``PRAGMA foreign_key_check``
|
||||
runs inside the same transaction before the stamp, so a corrupt
|
||||
copy aborts the whole migration instead of persisting. The pragma
|
||||
is a connection-level no-op inside a transaction, hence the
|
||||
off/transaction/on bracket.
|
||||
"""
|
||||
legacy_child_id = await _table_has_column(
|
||||
database, "quest_definitions", "child_id"
|
||||
)
|
||||
await database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
async with database.transaction():
|
||||
await database.execute(_ASSIGNEES_TABLE_DDL)
|
||||
if legacy_child_id:
|
||||
await database.execute(
|
||||
"CREATE TABLE quest_definitions_rebuilt "
|
||||
f"{QUEST_DEFINITIONS_TABLE_SQL}"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT INTO quest_definitions_rebuilt "
|
||||
"(id, title, description, icon, schedule_rule_id, "
|
||||
"due_time, is_active, created_at) "
|
||||
"SELECT id, title, description, icon, "
|
||||
"schedule_rule_id, due_time, is_active, created_at "
|
||||
"FROM quest_definitions ORDER BY id"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT OR IGNORE INTO quest_definition_assignees "
|
||||
"(definition_id, child_id) "
|
||||
"SELECT id, child_id FROM quest_definitions"
|
||||
)
|
||||
await database.execute("DROP TABLE quest_definitions")
|
||||
await database.execute(
|
||||
"ALTER TABLE quest_definitions_rebuilt "
|
||||
"RENAME TO quest_definitions"
|
||||
)
|
||||
violations = await database.fetch_all(
|
||||
"PRAGMA foreign_key_check"
|
||||
)
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
"definition rebuild produced foreign-key "
|
||||
f"violations: {violations!r}"
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
finally:
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _widen_instance_key(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Bring ``quest_instances`` to the D-008 instance key.
|
||||
|
||||
Databases whose instance table still carries the pre-window shape
|
||||
(no ``window`` column, UNIQUE on (definition_id, due_date)) are
|
||||
rebuilt via SQLite's canonical procedure into the widened shape —
|
||||
UNIQUE (definition_id, child_id, due_date, window) — with every
|
||||
migrated row landing in :data:`LEGACY_MIGRATED_WINDOW` so existing
|
||||
ids (and the completion events referencing them) survive intact.
|
||||
The old rows were unique per (definition, due_date), so pinning one
|
||||
window cannot collide with the widened key. Fresh databases whose
|
||||
table already matches the canonical shape only stamp.
|
||||
|
||||
The rebuild runs with ``foreign_keys = OFF`` (dropping the old
|
||||
table under enforcement would fire the implicit DELETE and violate
|
||||
the ``completion_events`` foreign key) and verifies
|
||||
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||
stamp, so a corrupt copy aborts the whole migration.
|
||||
"""
|
||||
legacy_shape = not await _table_has_column(
|
||||
database, "quest_instances", "window"
|
||||
)
|
||||
await database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
async with database.transaction():
|
||||
if legacy_shape:
|
||||
await database.execute(
|
||||
"CREATE TABLE quest_instances_rebuilt "
|
||||
f"{QUEST_INSTANCES_TABLE_SQL}"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT INTO quest_instances_rebuilt "
|
||||
"(id, definition_id, child_id, window, due_date, "
|
||||
"due_time, generated_at) "
|
||||
"SELECT id, definition_id, child_id, ?, due_date, "
|
||||
"due_time, generated_at "
|
||||
"FROM quest_instances ORDER BY id",
|
||||
(LEGACY_MIGRATED_WINDOW,),
|
||||
)
|
||||
await database.execute("DROP TABLE quest_instances")
|
||||
await database.execute(
|
||||
"ALTER TABLE quest_instances_rebuilt "
|
||||
"RENAME TO quest_instances"
|
||||
)
|
||||
violations = await database.fetch_all(
|
||||
"PRAGMA foreign_key_check"
|
||||
)
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
"instance rebuild produced foreign-key "
|
||||
f"violations: {violations!r}"
|
||||
)
|
||||
LOGGER.info(
|
||||
"Widened quest_instances key; migrated rows pinned "
|
||||
"to the %r window",
|
||||
LEGACY_MIGRATED_WINDOW,
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
finally:
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _add_actor_child_id(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Bring ``completion_events`` to the D-008 actor-child shape.
|
||||
|
||||
The ``actor_child_id`` column records which panel profile was
|
||||
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
|
||||
because the canonical shape also carries an actor-pair coherence
|
||||
CHECK (panel events require it, user events forbid it), and CHECK
|
||||
constraints cannot be added to an existing table. Databases with
|
||||
the legacy shape are therefore rebuilt via SQLite's canonical
|
||||
procedure, backfilling each legacy panel row's ``actor_child_id``
|
||||
to ``child_id`` — pre-D-008 panel completions were always recorded
|
||||
against the tapped child themselves — while admin rows stay NULL.
|
||||
Row ids are preserved so ``quest_instances`` references and the
|
||||
append-only history stay intact. Fresh databases already match
|
||||
the canonical shape and only stamp.
|
||||
|
||||
The rebuild runs with ``foreign_keys = OFF`` and verifies
|
||||
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
|
||||
completion_events table keeps its append-only guarantee.
|
||||
"""
|
||||
legacy_shape = not await _table_has_column(
|
||||
database, "completion_events", "actor_child_id"
|
||||
)
|
||||
await database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
async with database.transaction():
|
||||
if legacy_shape:
|
||||
await database.execute(
|
||||
"CREATE TABLE completion_events_rebuilt "
|
||||
f"{COMPLETION_EVENTS_TABLE_SQL}"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT INTO completion_events_rebuilt "
|
||||
"(id, instance_id, child_id, event_type, actor_source, "
|
||||
"actor_user_id, actor_child_id, occurred_at, "
|
||||
"was_on_time) "
|
||||
"SELECT id, instance_id, child_id, event_type, "
|
||||
"actor_source, actor_user_id, "
|
||||
"CASE WHEN actor_source = 'panel' THEN child_id "
|
||||
"ELSE NULL END, "
|
||||
"occurred_at, was_on_time "
|
||||
"FROM completion_events ORDER BY id"
|
||||
)
|
||||
await database.execute("DROP TABLE completion_events")
|
||||
await database.execute(
|
||||
"ALTER TABLE completion_events_rebuilt "
|
||||
"RENAME TO completion_events"
|
||||
)
|
||||
violations = await database.fetch_all(
|
||||
"PRAGMA foreign_key_check"
|
||||
)
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
"completion_events rebuild produced foreign-key "
|
||||
f"violations: {violations!r}"
|
||||
)
|
||||
LOGGER.info(
|
||||
"Added actor_child_id to completion_events; legacy "
|
||||
"panel rows backfilled to their own child_id"
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
finally:
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
#: The ordered migration list. Append-only: never edit an applied
|
||||
#: entry, add the next one instead. (Migration 1's content was
|
||||
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
||||
MIGRATIONS: Sequence[MigrationStep] = [
|
||||
MIGRATION_1_V1_DDL,
|
||||
_rename_legacy_task_tables,
|
||||
_add_definition_assignees,
|
||||
# Migration 4 (D-008): the windows table is a plain new table — a
|
||||
# CREATE IF NOT EXISTS suffices for both fresh databases (which
|
||||
# already made it in migration 1) and pre-window databases.
|
||||
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
|
||||
# Migration 5 (D-008): rebuild quest_instances onto the widened
|
||||
# (definition_id, child_id, due_date, window) key.
|
||||
_widen_instance_key,
|
||||
# Migration 6 (D-008): rebuild completion_events with the
|
||||
# actor_child_id column and its actor-pair coherence CHECK,
|
||||
# backfilling legacy panel rows to their own child_id.
|
||||
_add_actor_child_id,
|
||||
]
|
||||
|
||||
|
||||
async def _create_version_table(database: NestQuestDatabase) -> None:
|
||||
"""Create the version metadata table if it does not exist.
|
||||
|
||||
The table is created before any version check: it is the runner's
|
||||
own bookkeeping, and creating it is idempotent. The DDL enforces
|
||||
the singleton-row invariant (``id`` fixed at 1) and a positive
|
||||
integer version at the database level.
|
||||
"""
|
||||
await database.execute(VERSION_TABLE_DDL)
|
||||
|
||||
|
||||
async def read_schema_version(database: NestQuestDatabase) -> int:
|
||||
"""Return the schema version recorded in the database.
|
||||
|
||||
Returns 0 when the version table or its row is missing — the
|
||||
"fresh file" case. A stored version higher than the runner knows
|
||||
is surfaced as-is; the caller decides how to treat a future version.
|
||||
|
||||
Reading is deliberately passive: a missing table raises, it is not
|
||||
swallowed into 0, because :func:`apply_migrations` always creates
|
||||
the table before reading and a direct reader hitting "no such
|
||||
table" is a caller bug worth surfacing, not a fresh file.
|
||||
"""
|
||||
row = await database.fetch_one(
|
||||
f"SELECT version FROM {VERSION_TABLE} LIMIT 1"
|
||||
)
|
||||
if row is None:
|
||||
return 0
|
||||
version = row[0]
|
||||
if not isinstance(version, int) or isinstance(version, bool):
|
||||
raise RuntimeError(
|
||||
f"Schema version metadata is not an integer: {version!r}"
|
||||
)
|
||||
if version < 1:
|
||||
raise RuntimeError(
|
||||
f"Schema version metadata is invalid (must be >= 1): {version!r}"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
#: One asyncio.Lock per connection wrapper, created lazily and bound to
|
||||
#: the running loop, serializing whole apply_migrations runs so two
|
||||
#: concurrent callers on one connection queue instead of colliding on
|
||||
#: the wrapper's single-transaction rule.
|
||||
_MIGRATION_LOCKS: dict[int, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
|
||||
"""Return the migration lock bound to ``database``'s running loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
key = (id(database), id(loop))
|
||||
lock = _MIGRATION_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_MIGRATION_LOCKS[key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
async def apply_migrations(
|
||||
database: NestQuestDatabase,
|
||||
migrations: Sequence[MigrationStep] = MIGRATIONS,
|
||||
) -> int:
|
||||
"""Bring the database schema up to the latest version.
|
||||
|
||||
Reads the current version, applies every pending migration in order
|
||||
— each inside one transaction that also stamps the new version —
|
||||
and returns the resulting version. Applying zero pending
|
||||
migrations (database already current) is a no-op that logs at debug
|
||||
level and opens no write transaction.
|
||||
|
||||
The version is re-read inside each migration transaction before
|
||||
deciding to apply it: the outer read is advisory only (loop bounds,
|
||||
no-op fast path), and on one shared connection two interleaved
|
||||
runners would otherwise both read the same version and both apply
|
||||
a non-idempotent migration. Under the transaction's lock the
|
||||
second runner sees the first runner's stamp and skips.
|
||||
|
||||
Runners on one connection are serialized by an asyncio lock held
|
||||
for the whole decide-and-apply span: NestQuestDatabase rejects a
|
||||
second transaction while one is active (RuntimeError), so
|
||||
concurrent callers must queue, and the lock makes that queueing
|
||||
explicit. The loser of the race re-reads the version once it
|
||||
acquires the lock, sees the winner's stamp, and applies nothing.
|
||||
|
||||
Raises whatever the failing migration raised, with the database
|
||||
rolled back to its prior version and prior rows untouched.
|
||||
"""
|
||||
async with _migration_lock(database):
|
||||
await _create_version_table(database)
|
||||
current = await read_schema_version(database)
|
||||
latest = len(migrations)
|
||||
|
||||
if current > latest:
|
||||
raise RuntimeError(
|
||||
f"Database schema version {current} is newer than this "
|
||||
f"integration understands (latest known: {latest}); "
|
||||
"downgrades are not supported"
|
||||
)
|
||||
|
||||
if current == latest:
|
||||
LOGGER.debug(
|
||||
"NestQuest schema already at version %d; "
|
||||
"no migrations to apply",
|
||||
current,
|
||||
)
|
||||
return current
|
||||
|
||||
for target in range(current + 1, latest + 1):
|
||||
applied = False
|
||||
step = migrations[target - 1]
|
||||
if callable(step):
|
||||
# Callable migrations own their transaction: some steps
|
||||
# (table rebuilds) must run connection pragmas such as
|
||||
# ``foreign_keys = OFF`` BEFORE opening one, which the
|
||||
# runner's uniform transaction cannot express, and they
|
||||
# stamp inside their own transaction so a failure rolls
|
||||
# the statements and the version stamp back together.
|
||||
# The version is re-read here under the migration lock:
|
||||
# runners on one connection are serialized by it, so
|
||||
# the version cannot change between this read and the
|
||||
# callable's commit.
|
||||
in_tx_version = await read_schema_version(database)
|
||||
if in_tx_version > latest:
|
||||
raise RuntimeError(
|
||||
f"Database schema version {in_tx_version} is newer "
|
||||
f"than this integration understands (latest known: "
|
||||
f"{latest}); downgrades are not supported"
|
||||
)
|
||||
if in_tx_version < target:
|
||||
await step(database, target)
|
||||
applied = True
|
||||
else:
|
||||
async with database.transaction():
|
||||
# Re-read the version INSIDE the write transaction:
|
||||
# the outer read was advisory, and the wrapper
|
||||
# serializes individual statements, not the
|
||||
# read-decide-apply decision. Under the
|
||||
# transaction's lock the version cannot change
|
||||
# between the read and the stamp, and the loser of
|
||||
# an interleaved race (a queued second caller
|
||||
# re-running apply_migrations after the winner
|
||||
# already migrated) re-reads here, sees the
|
||||
# winner's stamp, and skips instead of re-applying
|
||||
# a non-idempotent migration with a stale version.
|
||||
in_tx_version = await read_schema_version(database)
|
||||
if in_tx_version > latest:
|
||||
raise RuntimeError(
|
||||
f"Database schema version {in_tx_version} is "
|
||||
f"newer than this integration understands "
|
||||
f"(latest known: {latest}); downgrades are "
|
||||
f"not supported"
|
||||
)
|
||||
if in_tx_version < target:
|
||||
for sql in step:
|
||||
await database.execute(sql)
|
||||
await stamp_schema_version(database, target)
|
||||
applied = True
|
||||
applied_statements = len(step)
|
||||
if applied:
|
||||
LOGGER.info(
|
||||
"NestQuest schema migrated to version %d",
|
||||
target,
|
||||
)
|
||||
|
||||
return latest
|
||||
from .core.migrations import * # noqa: F401,F403
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Options flow for NestQuest post-setup settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -11,25 +10,39 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
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_UPDATE_INTERVAL,
|
||||
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_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||
|
||||
MIN_HORIZON_DAYS = 1
|
||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
||||
|
||||
_TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
||||
|
||||
|
||||
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
|
||||
return TIME_PATTERN.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
@@ -56,9 +69,63 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
):
|
||||
errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid"
|
||||
|
||||
# The coordinator refresh interval (Feature 10) is OPTIONAL in raw
|
||||
# submits: a missing key keeps the current value, so pre-Feature-10
|
||||
# form payloads (and API callers) are unaffected. Schema-validated
|
||||
# submits always carry it.
|
||||
update_interval = user_input.get(CONF_UPDATE_INTERVAL)
|
||||
if update_interval is not None and (
|
||||
isinstance(update_interval, bool)
|
||||
or not isinstance(update_interval, int)
|
||||
or update_interval < MIN_UPDATE_INTERVAL
|
||||
):
|
||||
errors[CONF_UPDATE_INTERVAL] = "invalid"
|
||||
|
||||
# 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,
|
||||
):
|
||||
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)
|
||||
):
|
||||
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()
|
||||
):
|
||||
# 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 _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],
|
||||
*,
|
||||
@@ -70,7 +137,10 @@ def _build_schema(
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
fields: dict[Any, Any] = {
|
||||
vol.Required(
|
||||
@@ -82,6 +152,40 @@ def _build_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,
|
||||
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
|
||||
@@ -210,7 +314,28 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
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()
|
||||
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[notification_key] = user_input.get(
|
||||
notification_key, current[notification_key]
|
||||
)
|
||||
if admin_ids_present:
|
||||
data[CONF_ADMIN_USER_IDS] = admin_ids
|
||||
elif admin_default:
|
||||
@@ -231,6 +356,15 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
(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_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]
|
||||
|
||||
@@ -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
|
||||
@@ -1,477 +1,11 @@
|
||||
"""The presence and custody model (Feature 05).
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.presence` for the integration package.
|
||||
|
||||
PURE module: no Home Assistant imports, no database access, no
|
||||
timezone logic — it operates on plain calendar dates only (feature
|
||||
guardrail). The later engine (cycle position, ``is_present``,
|
||||
overrides, previews) builds directly on this model.
|
||||
|
||||
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
|
||||
week is this child at this house?" for an N-week repeating custody
|
||||
pattern. Two of the household's three children follow a two-week
|
||||
rotation; a child with NO schedule row is present every day (that
|
||||
default lives in the engine, not here).
|
||||
|
||||
Pattern encoding: the model's canonical serialization is the SAME
|
||||
pipe-separated CSV the database stores (see schema.py's
|
||||
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
|
||||
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
|
||||
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
|
||||
empty segment means absent every day of that week. Encoding sorts
|
||||
each segment's weekdays and uses the exact segment order, so
|
||||
``decode(encode(schedule))`` round-trips losslessly and two schedules
|
||||
with equal patterns produce equal strings.
|
||||
|
||||
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
|
||||
computed from anchor-date arithmetic ONLY — never ISO week numbers or
|
||||
week parity (D-004): 53-week years silently invert parity on January
|
||||
1st. The arithmetic lives in the engine task; this module validates
|
||||
that the anchor is a strict calendar date.
|
||||
|
||||
Validation policy: construction is total — every reject happens in
|
||||
``__init__`` so no invalid schedule can ever exist:
|
||||
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
|
||||
the database CHECK (schema.py) and keeps anchor arithmetic and the
|
||||
encoded pattern human-readable; revisit both together if a longer
|
||||
cycle is ever needed.
|
||||
- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly
|
||||
(no gaps, no extra weeks) with sets of integers 0..6 (bools
|
||||
rejected: True would silently mean Monday).
|
||||
- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a
|
||||
``datetime.date``).
|
||||
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
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
|
||||
#: The schema's documented cap for cycle_length_weeks (schema.py keeps
|
||||
#: the pattern human-readable and the anchor arithmetic small).
|
||||
MAX_CYCLE_LENGTH_WEEKS = 4
|
||||
|
||||
_DATE_FORMAT = "%Y-%m-%d"
|
||||
|
||||
|
||||
def _parse_date(value: object, field_name: str) -> datetime.date:
|
||||
"""Accept a datetime.date or a strict YYYY-MM-DD string.
|
||||
|
||||
``datetime.datetime`` is rejected explicitly: it SUBCLASSES
|
||||
datetime.date, so a bare isinstance check would store a value with
|
||||
a time component and every later calendar-date comparison (e.g.
|
||||
``PresenceOverride.covers``) would raise TypeError.
|
||||
"""
|
||||
if isinstance(value, datetime.datetime):
|
||||
raise ValueError(
|
||||
f"{field_name} must be a plain calendar date, not a "
|
||||
f"datetime with a time component: {value!r}"
|
||||
)
|
||||
if isinstance(value, datetime.date):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"{field_name} must be a datetime.date or an ISO date string, "
|
||||
f"got {value!r}"
|
||||
)
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() != value:
|
||||
raise ValueError(
|
||||
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _validate_week_index(week: object, cycle_length_weeks: int) -> int:
|
||||
"""Reject non-int week indices (bools included) and out-of-cycle."""
|
||||
if isinstance(week, bool) or not isinstance(week, int):
|
||||
raise ValueError(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
)
|
||||
if not 0 <= week < cycle_length_weeks:
|
||||
raise ValueError(
|
||||
f"pattern week index must be in 0..{cycle_length_weeks - 1}, "
|
||||
f"got {week}"
|
||||
)
|
||||
return week
|
||||
|
||||
|
||||
def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
|
||||
"""Validate one week's present-weekday set."""
|
||||
if isinstance(weekdays, (str, bytes)) or weekdays is None:
|
||||
raise ValueError(
|
||||
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
|
||||
f"got {weekdays!r}"
|
||||
)
|
||||
try:
|
||||
members = list(weekdays)
|
||||
except TypeError:
|
||||
raise ValueError(
|
||||
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
|
||||
f"got {weekdays!r}"
|
||||
) from None
|
||||
for member in members:
|
||||
if isinstance(member, bool) or not isinstance(member, int):
|
||||
raise ValueError(
|
||||
f"pattern[{week}] weekday must be an int 0..6, got {member!r}"
|
||||
)
|
||||
if not 0 <= member <= 6:
|
||||
raise ValueError(
|
||||
f"pattern[{week}] weekday must be in 0..6 (Monday=0), "
|
||||
f"got {member}"
|
||||
)
|
||||
return frozenset(members)
|
||||
|
||||
|
||||
def _validate_cycle_length(value: object) -> int:
|
||||
"""Reject non-int and out-of-range cycle lengths (bools included)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"cycle_length_weeks must be an integer, got {value!r}"
|
||||
)
|
||||
if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS:
|
||||
raise ValueError(
|
||||
f"cycle_length_weeks must be between 1 and "
|
||||
f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools would silently address child 1)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class PresenceSchedule:
|
||||
"""One child's N-week repeating presence pattern.
|
||||
|
||||
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
|
||||
to the set of present weekdays (ints, Monday=0). A week with an
|
||||
empty set means the child is absent every day of that week — which
|
||||
is distinct from having no schedule at all (the engine treats a
|
||||
missing schedule as present every day).
|
||||
"""
|
||||
|
||||
child_id: int
|
||||
cycle_length_weeks: int
|
||||
anchor_date: datetime.date
|
||||
pattern: MappingProxyType # week index -> frozenset of weekdays
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
child_id: int,
|
||||
cycle_length_weeks: int,
|
||||
anchor_date: object,
|
||||
pattern: dict[int, object],
|
||||
) -> None:
|
||||
object.__setattr__(self, "child_id", _validate_child_id(child_id))
|
||||
cycle = _validate_cycle_length(cycle_length_weeks)
|
||||
object.__setattr__(self, "cycle_length_weeks", cycle)
|
||||
object.__setattr__(
|
||||
self, "anchor_date", _parse_date(anchor_date, "anchor_date")
|
||||
)
|
||||
if not isinstance(pattern, dict):
|
||||
raise ValueError(
|
||||
"pattern must map week index to a set of present "
|
||||
f"weekdays, got {pattern!r}"
|
||||
)
|
||||
normalised: dict[int, frozenset[int]] = {}
|
||||
for week, weekdays in pattern.items():
|
||||
index = _validate_week_index(week, cycle)
|
||||
normalised[index] = _validate_weekday_set(index, weekdays)
|
||||
missing = [
|
||||
week for week in range(cycle) if week not in normalised
|
||||
]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"pattern must cover week indices 0..{cycle - 1}; "
|
||||
f"missing {missing}"
|
||||
)
|
||||
# Frozen must mean frozen: a bare dict would let callers inject
|
||||
# unvalidated weekdays (or drop required weeks) AFTER
|
||||
# construction, bypassing total validation. The mapping
|
||||
# proxy exposes the validated contents read-only; the
|
||||
# per-week sets are already frozensets.
|
||||
object.__setattr__(self, "pattern", MappingProxyType(normalised))
|
||||
|
||||
def encode(self) -> str:
|
||||
"""Serialize to the database's pipe-separated CSV shape.
|
||||
|
||||
Segments appear in week order; each segment is the week's
|
||||
present weekdays as a sorted comma-separated list (an empty set
|
||||
encodes as an empty segment). Two schedules with equal
|
||||
patterns encode identically.
|
||||
"""
|
||||
segments = [
|
||||
",".join(str(day) for day in sorted(self.pattern[week]))
|
||||
for week in range(self.cycle_length_weeks)
|
||||
]
|
||||
return "|".join(segments)
|
||||
|
||||
@classmethod
|
||||
def decode(
|
||||
cls,
|
||||
child_id: int,
|
||||
anchor_date: object,
|
||||
encoded: str,
|
||||
) -> "PresenceSchedule":
|
||||
"""Build a schedule from its encoded pattern.
|
||||
|
||||
The cycle length is derived from the segment count, mirroring
|
||||
the database's segment-count CHECK. Decoding is strict: wrong
|
||||
shapes, non-numeric or out-of-range weekdays are rejected —
|
||||
the database CHECKs make such rows unrepresentable, and this
|
||||
is the same contract on the model side. A segment count above
|
||||
the documented cycle cap is rejected by the ordinary
|
||||
construction validation.
|
||||
"""
|
||||
if not isinstance(encoded, str):
|
||||
raise ValueError(
|
||||
f"encoded pattern must be a string, got {encoded!r}"
|
||||
)
|
||||
segments = encoded.split("|")
|
||||
pattern: dict[int, set[int]] = {}
|
||||
for week, segment in enumerate(segments):
|
||||
weekdays: set[int] = set()
|
||||
if segment:
|
||||
for part in segment.split(","):
|
||||
if len(part) != 1 or part not in "0123456":
|
||||
raise ValueError(
|
||||
f"encoded segment {week} carries an invalid "
|
||||
f"weekday {part!r} (single digits 0-6 only)"
|
||||
)
|
||||
weekdays.add(int(part))
|
||||
pattern[week] = weekdays
|
||||
return cls(child_id, len(segments), anchor_date, pattern)
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class PresenceOverride:
|
||||
"""A date-specific present/absent flag beating any pattern.
|
||||
|
||||
Covers a single date (``end_date == start_date``) or an inclusive
|
||||
range. A concrete range is the storage shape
|
||||
(``presence_overrides.end_date`` is NOT NULL), so the model
|
||||
requires both bounds and validates start <= end. Construction and
|
||||
validation live here so the engine can trust every instance.
|
||||
"""
|
||||
|
||||
child_id: int
|
||||
start_date: datetime.date
|
||||
end_date: datetime.date
|
||||
is_present: bool
|
||||
note: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
child_id: int,
|
||||
start_date: object,
|
||||
end_date: object,
|
||||
is_present: bool,
|
||||
note: str | None = None,
|
||||
) -> None:
|
||||
object.__setattr__(self, "child_id", _validate_child_id(child_id))
|
||||
start = _parse_date(start_date, "start_date")
|
||||
end = _parse_date(end_date, "end_date")
|
||||
if end < start:
|
||||
raise ValueError(
|
||||
f"end_date must be on or after start_date, got "
|
||||
f"{end.isoformat()} < {start.isoformat()}"
|
||||
)
|
||||
object.__setattr__(self, "start_date", start)
|
||||
object.__setattr__(self, "end_date", end)
|
||||
if not isinstance(is_present, bool):
|
||||
raise ValueError(
|
||||
f"is_present must be a real bool, got {is_present!r}"
|
||||
)
|
||||
object.__setattr__(self, "is_present", is_present)
|
||||
if note is not None and not isinstance(note, str):
|
||||
raise ValueError(f"note must be a string or None, got {note!r}")
|
||||
object.__setattr__(self, "note", note)
|
||||
|
||||
def covers(self, date: datetime.date) -> bool:
|
||||
"""Return True when the override applies to ``date``."""
|
||||
return self.start_date <= date <= self.end_date
|
||||
|
||||
|
||||
def cycle_week_index(
|
||||
schedule: PresenceSchedule, target_date: object
|
||||
) -> int:
|
||||
"""Return the cycle week index ``target_date`` falls in.
|
||||
|
||||
Anchor-date arithmetic ONLY (D-004): ``((target_date -
|
||||
anchor_date).days // 7) % cycle_length_weeks``. Python's floor
|
||||
division makes dates BEFORE the anchor behave correctly — they
|
||||
walk the cycle backwards (day -1 is the last week of the cycle,
|
||||
day -8 wraps one further) instead of inverting, which is exactly
|
||||
what ISO week parity does wrong in 53-week years. The modulo of
|
||||
a negative in Python is non-negative, so the index is always a
|
||||
valid week index.
|
||||
"""
|
||||
target = _parse_date(target_date, "target_date")
|
||||
days = (target - schedule.anchor_date).days
|
||||
return (days // 7) % schedule.cycle_length_weeks
|
||||
|
||||
|
||||
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
|
||||
#: years of daily checks. A child absent every day (an all-absent
|
||||
#: pattern with a blocking override) would otherwise loop forever; the
|
||||
#: cap bounds the scan and surfaces the situation as an error instead.
|
||||
MAX_PREVIEW_SCAN_DAYS = 366 * 2
|
||||
|
||||
|
||||
class PresenceEngine:
|
||||
"""Answers "is this child at this house on this date?".
|
||||
|
||||
Pure and side-effect-free: built from an immutable snapshot of the
|
||||
children's schedules (``child_id -> PresenceSchedule``), it never
|
||||
touches the database — callers build a snapshot from the DAO layer
|
||||
and pass it in. A child with NO schedule entry is present every
|
||||
day (the third household child's case); a child WITH a schedule is
|
||||
present exactly when the date's weekday is in that schedule's
|
||||
pattern for the date's cycle week index (anchor-date arithmetic —
|
||||
:func:`cycle_week_index`, never ISO week parity).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schedules: dict[int, PresenceSchedule],
|
||||
overrides: dict[int, list[PresenceOverride]] | None = None,
|
||||
) -> None:
|
||||
if not isinstance(schedules, dict):
|
||||
raise ValueError(
|
||||
f"schedules must map child_id to PresenceSchedule, got "
|
||||
f"{schedules!r}"
|
||||
)
|
||||
for child_id, schedule in schedules.items():
|
||||
# Validate the KEY as a model child id too: True and 1.0
|
||||
# compare equal to 1, so {True: schedule(1)} would be
|
||||
# accepted and then behave differently on lookup.
|
||||
_validate_child_id(child_id)
|
||||
if not isinstance(schedule, PresenceSchedule):
|
||||
raise ValueError(
|
||||
f"schedules[{child_id!r}] must be a PresenceSchedule, "
|
||||
f"got {schedule!r}"
|
||||
)
|
||||
if schedule.child_id != child_id:
|
||||
raise ValueError(
|
||||
f"schedules is keyed {child_id!r} but holds the "
|
||||
f"schedule of child {schedule.child_id}"
|
||||
)
|
||||
if overrides is None:
|
||||
overrides = {}
|
||||
if not isinstance(overrides, dict):
|
||||
raise ValueError(
|
||||
"overrides must map child_id to a list of "
|
||||
f"PresenceOverride, got {overrides!r}"
|
||||
)
|
||||
for child_id, entries in overrides.items():
|
||||
_validate_child_id(child_id)
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError(
|
||||
f"overrides[{child_id!r}] must be a list of "
|
||||
f"PresenceOverride, got {entries!r}"
|
||||
)
|
||||
for entry in entries:
|
||||
if not isinstance(entry, PresenceOverride):
|
||||
raise ValueError(
|
||||
f"overrides[{child_id!r}] entries must be "
|
||||
f"PresenceOverride, got {entry!r}"
|
||||
)
|
||||
if entry.child_id != child_id:
|
||||
raise ValueError(
|
||||
f"overrides is keyed {child_id!r} but holds an "
|
||||
f"override of child {entry.child_id}"
|
||||
)
|
||||
# Read-only snapshot: reassignment is refused and no mutator
|
||||
# exists, so a built engine answers consistently for its
|
||||
# lifetime. The overrides lists are copied to immutable
|
||||
# tuples — a shallow mapping copy alone would leave caller-
|
||||
# owned lists reachable through _overrides, and mutating one
|
||||
# later would change is_present results.
|
||||
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"_overrides",
|
||||
MappingProxyType(
|
||||
{child_id: tuple(entries) for child_id, entries in overrides.items()}
|
||||
),
|
||||
)
|
||||
|
||||
def is_present(self, child_id: object, date: object) -> bool:
|
||||
"""Return whether ``child_id`` is present on ``date``.
|
||||
|
||||
Overrides take precedence over the repeating pattern: when an
|
||||
override covers the date, its flag IS the answer — including
|
||||
for a child with no schedule at all. The DAO layer guarantees
|
||||
a child's overrides never overlap; if a snapshot still carries
|
||||
two covering the same date, the one with the LATEST start date
|
||||
wins (deterministic, and matches "the most specific swap wins").
|
||||
"""
|
||||
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||
target = _parse_date(date, "date")
|
||||
entries = self._overrides.get(child_id) or []
|
||||
covering = [
|
||||
entry for entry in entries if entry.covers(target)
|
||||
]
|
||||
if covering:
|
||||
winner = max(covering, key=lambda entry: entry.start_date)
|
||||
return winner.is_present
|
||||
schedule = self._schedules.get(child_id)
|
||||
if schedule is None:
|
||||
# No schedule at all: present every day (household default).
|
||||
return True
|
||||
week = cycle_week_index(schedule, target)
|
||||
return target.weekday() in schedule.pattern[week]
|
||||
|
||||
def next_present_dates(
|
||||
self,
|
||||
child_id: object,
|
||||
start_date: object,
|
||||
count: int,
|
||||
) -> list[datetime.date]:
|
||||
"""Return the next ``count`` dates the child is present.
|
||||
|
||||
Walks forward from ``start_date`` (INCLUSIVE — a parent
|
||||
previewing "starting Monday" expects Monday itself when the
|
||||
child is present) checking :meth:`is_present` for every day,
|
||||
so overrides and the pattern are honoured identically. The
|
||||
admin Schedule tab renders this preview so a parent can confirm
|
||||
a custody pattern is right before saving it.
|
||||
|
||||
A child absent every scanned day raises ValueError after the
|
||||
two-year search cap — an always-absent child would otherwise
|
||||
loop forever. ``count`` must be a positive integer.
|
||||
"""
|
||||
_validate_child_id(child_id)
|
||||
start = _parse_date(start_date, "start_date")
|
||||
if isinstance(count, bool) or not isinstance(count, int):
|
||||
raise ValueError(f"count must be an integer, got {count!r}")
|
||||
if count < 1:
|
||||
raise ValueError(f"count must be at least 1, got {count}")
|
||||
found: list[datetime.date] = []
|
||||
day = start
|
||||
for _ in range(MAX_PREVIEW_SCAN_DAYS):
|
||||
if self.is_present(child_id, day):
|
||||
found.append(day)
|
||||
if len(found) == count:
|
||||
return found
|
||||
day += datetime.timedelta(days=1)
|
||||
raise ValueError(
|
||||
f"child {child_id} is not present on any of the "
|
||||
f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; "
|
||||
f"{count} present day(s) cannot be previewed"
|
||||
)
|
||||
|
||||
def __setattr__(self, name: object, value: object) -> None:
|
||||
raise AttributeError(
|
||||
"PresenceEngine is an immutable snapshot; build a new engine "
|
||||
"instead of mutating this one"
|
||||
)
|
||||
from .core.presence import * # noqa: F401,F403
|
||||
|
||||
@@ -1,228 +1,11 @@
|
||||
"""Business layer for creating quest definitions (Feature 06).
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.quest_definitions` for the integration package.
|
||||
|
||||
Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
|
||||
all-or-nothing create here, and never touch the DAO SQL directly.
|
||||
:func:`create_quest_definition` is the create path; there is no
|
||||
permission check here (that is Feature 09).
|
||||
|
||||
Validation policy mirrors :mod:`.children`:
|
||||
|
||||
- ``title`` is required and trimmed; empty or whitespace-only raises
|
||||
ValueError.
|
||||
- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
|
||||
(its constructor is total — rule validation is NOT re-implemented
|
||||
here).
|
||||
- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``);
|
||||
every id is a plain int (bools and floats rejected, since SQLite would
|
||||
bind them onto a real child) and must reference an existing ACTIVE
|
||||
child (the DAO enforces the active/exists check inside the same
|
||||
transaction). Duplicate ids are rejected here, before the composite
|
||||
primary key could surface them as an :class:`sqlite3.IntegrityError`.
|
||||
- ``windows`` must be a non-empty list (strictly a ``list``); each entry
|
||||
is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare
|
||||
name (no due time) or a ``(name, due_time)`` pair whose due time is a
|
||||
strict 24-hour HH:MM string. Duplicate window names are rejected
|
||||
here, before the composite primary key could surface them as an
|
||||
:class:`sqlite3.IntegrityError`.
|
||||
|
||||
The rule, definition, assignees and windows are persisted in ONE
|
||||
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
|
||||
so a rejected or failed create persists nothing. The definition-level
|
||||
``due_time`` column is superseded by per-window due times (D-008) and is
|
||||
never set here. ``created_at`` is stamped UTC ISO-8601 at second
|
||||
precision, matching :mod:`.children`.
|
||||
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
|
||||
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import ChildRecord
|
||||
from .dao_rules import (
|
||||
QuestDefinitionRecord,
|
||||
QuestDefinitionWindowRecord,
|
||||
QuestDefinitionsDao,
|
||||
_validate_time,
|
||||
_validate_window,
|
||||
schedule_rule_from_storage,
|
||||
schedule_rule_to_storage,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .recurrence import ScheduleRule
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreatedQuestDefinition:
|
||||
"""The persisted result of :func:`create_quest_definition`.
|
||||
|
||||
``definition`` is the stored row; ``rule`` is the schedule rule
|
||||
decoded back through the storage mapping; ``assignees`` and
|
||||
``windows`` are the definition's linked child records and window
|
||||
declarations, read through the DAO.
|
||||
"""
|
||||
|
||||
definition: QuestDefinitionRecord
|
||||
rule: ScheduleRule
|
||||
assignees: list[ChildRecord]
|
||||
windows: list[QuestDefinitionWindowRecord]
|
||||
|
||||
|
||||
def _now_stamp() -> str:
|
||||
"""Return the module's strict UTC second-precision stamp."""
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
|
||||
|
||||
def _validate_text(
|
||||
value: object, field: str, *, required: bool = False
|
||||
) -> str | None:
|
||||
"""Validate a free-text field and return it trimmed.
|
||||
|
||||
``None`` raises when ``required`` and otherwise returns None. A
|
||||
non-string value is rejected; an empty result is rejected for
|
||||
required fields.
|
||||
"""
|
||||
if value is None:
|
||||
if required:
|
||||
raise ValueError(f"{field} is required")
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{field} must be a string, got {value!r}")
|
||||
trimmed = value.strip()
|
||||
if required and not trimmed:
|
||||
raise ValueError(f"{field} is required")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _validate_assignee_ids(value: object) -> list[int]:
|
||||
"""Return the assignee ids as plain ints, or raise naming the field.
|
||||
|
||||
The argument must be a non-empty list — strictly a ``list``: tuples
|
||||
and other sequences are rejected. Each entry must be a real int
|
||||
(bools and floats rejected) so SQLite cannot bind ``True`` onto
|
||||
child 1 or a float onto a neighbouring profile.
|
||||
"""
|
||||
if type(value) is not list:
|
||||
raise ValueError(
|
||||
f"assignee_child_ids must be a list, got {type(value).__name__}"
|
||||
)
|
||||
if not value:
|
||||
raise ValueError("assignee_child_ids must not be empty")
|
||||
ids: list[int] = []
|
||||
for entry in value:
|
||||
if isinstance(entry, bool) or not isinstance(entry, int):
|
||||
raise ValueError(
|
||||
"assignee_child_ids must contain integer child ids, "
|
||||
f"got {entry!r}"
|
||||
)
|
||||
ids.append(entry)
|
||||
if len(set(ids)) != len(ids):
|
||||
raise ValueError(
|
||||
"assignee_child_ids must not contain duplicate child ids"
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
def _normalize_windows(
|
||||
value: object,
|
||||
) -> list[tuple[str, str | None]]:
|
||||
"""Normalize the windows argument to ``(name, due_time)`` pairs.
|
||||
|
||||
The outer argument must be a non-empty list — strictly a ``list``:
|
||||
tuples and other sequences are rejected, while each individual entry
|
||||
may still be a ``(window, due_time)`` tuple. A bare window name
|
||||
(due time None) is also accepted. The name must be a
|
||||
:data:`~.const.QUEST_WINDOWS` spelling and a present due time must be
|
||||
a strict 24-hour HH:MM, both validated up front so a malformed
|
||||
window is rejected before any write.
|
||||
"""
|
||||
if type(value) is not list:
|
||||
raise ValueError(
|
||||
f"windows must be a list, got {type(value).__name__}"
|
||||
)
|
||||
if not value:
|
||||
raise ValueError("windows must not be empty")
|
||||
normalized: list[tuple[str, str | None]] = []
|
||||
seen: set[str] = set()
|
||||
for index, entry in enumerate(value):
|
||||
if isinstance(entry, str):
|
||||
name: object = entry
|
||||
due_time: object = None
|
||||
elif isinstance(entry, (tuple, list)) and len(entry) == 2:
|
||||
name, due_time = entry[0], entry[1]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"windows[{index}] must be a window name or a "
|
||||
f"(name, due_time) pair, got {entry!r}"
|
||||
)
|
||||
_validate_window(name)
|
||||
if due_time is not None:
|
||||
_validate_time(due_time, "due_time")
|
||||
if name in seen:
|
||||
raise ValueError(
|
||||
f"windows must not contain duplicate window {name!r}"
|
||||
)
|
||||
seen.add(name)
|
||||
normalized.append((name, due_time))
|
||||
return normalized
|
||||
|
||||
|
||||
async def create_quest_definition(
|
||||
database: NestQuestDatabase,
|
||||
title: str,
|
||||
rule: ScheduleRule,
|
||||
assignee_child_ids: list[int],
|
||||
windows: list[str | tuple[str, str | None]],
|
||||
*,
|
||||
description: str | None = None,
|
||||
icon: str | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Create a quest definition (rule + assignees + windows) atomically.
|
||||
|
||||
Validates the arguments up front, then persists the schedule rule,
|
||||
the definition, its assignees and its windows in one transaction via
|
||||
the DAO. Returns the stored definition together with its decoded
|
||||
rule, assignees and windows — all read back inside that same
|
||||
transaction, so the returned bundle is a consistent creation
|
||||
snapshot. Raises ValueError on any rejected argument; a rejected or
|
||||
failed create persists nothing.
|
||||
"""
|
||||
name = _validate_text(title, "title", required=True)
|
||||
assert name is not None
|
||||
if not isinstance(rule, ScheduleRule):
|
||||
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
|
||||
assignee_ids = _validate_assignee_ids(assignee_child_ids)
|
||||
window_specs = _normalize_windows(windows)
|
||||
description_value = _validate_text(description, "description")
|
||||
icon_value = _validate_text(icon, "icon")
|
||||
|
||||
storage = schedule_rule_to_storage(rule)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
snapshot = await dao.create_with_rule_and_windows(
|
||||
name,
|
||||
storage,
|
||||
_now_stamp(),
|
||||
assignee_ids,
|
||||
window_specs,
|
||||
description=description_value,
|
||||
icon=icon_value,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's assignee check names only the child; re-raise so the
|
||||
# public contract names the field the caller actually passed.
|
||||
message = str(error)
|
||||
if message.startswith("child "):
|
||||
raise ValueError(
|
||||
f"assignee_child_ids: {message}"
|
||||
) from error
|
||||
raise
|
||||
decoded_rule = schedule_rule_from_storage(storage)
|
||||
return CreatedQuestDefinition(
|
||||
definition=snapshot.definition,
|
||||
rule=decoded_rule,
|
||||
assignees=snapshot.assignees,
|
||||
windows=snapshot.windows,
|
||||
)
|
||||
from .core.quest_definitions import * # noqa: F401,F403
|
||||
|
||||
@@ -1,621 +1,10 @@
|
||||
"""The ScheduleRule model: rule types, validation, and serialization.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package.
|
||||
|
||||
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
|
||||
frozen dataclass with no Home Assistant and no database imports. The
|
||||
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
|
||||
serializes its dict form into the ``schedule_rules`` table.
|
||||
|
||||
Type model: one enum member per recurrence shape. ``MONTHLY_DAY``
|
||||
("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second
|
||||
Tuesday of every month") are distinct types rather than one monthly
|
||||
type with optional fields, so validation is total: a rule either
|
||||
constructs, or raises :class:`RuleValidationError` naming the offending
|
||||
field. Every type carries ONLY the fields its shape needs; carrying
|
||||
any other shape's field is a construction error.
|
||||
|
||||
Storage mapping (Feature 02, ``schedule_rules`` table): the enum's
|
||||
``storage_value`` maps model types onto the schema's CHECK values —
|
||||
MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are
|
||||
disambiguated by which fields are populated; CUSTOM_DAYS stores as
|
||||
``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the
|
||||
table stores (one element for MONTHLY_WEEKDAY, which names the
|
||||
position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field
|
||||
carried through from_dict/to_dict for losslessness and folded into
|
||||
``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the
|
||||
model-side lossless pair; the DB mapping lives in the DAO layer and is
|
||||
exercised there.
|
||||
|
||||
Validation policy (fail fast at construction — the schema's CHECK
|
||||
constraints are a second line of defense, but invalid rules must never
|
||||
reach storage):
|
||||
|
||||
- ``interval`` >= 1 for every rule.
|
||||
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
|
||||
elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans.
|
||||
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
|
||||
- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is
|
||||
rejected) with a weekday 0..6 for MONTHLY_WEEKDAY.
|
||||
- ``month`` 1..12 is required for YEARLY.
|
||||
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
|
||||
when present, on or after ``start_date``.
|
||||
|
||||
Month-end policy (implemented in :func:`_occurs_monthly_day` and
|
||||
documented for Feature 04): in each interval-eligible month, a
|
||||
MONTHLY_DAY rule for a day that does not exist in that month — the
|
||||
31st in April, the 30th or 29th in February, February 29th in a
|
||||
non-leap year — fires on the LAST day of the month instead of being
|
||||
skipped. A monthly chore still happens in every eligible month,
|
||||
pinned to the month's end when its nominal date is absent; exactly one
|
||||
day per eligible month matches the rule (nominal day when it exists,
|
||||
month end when it does not). The interval anchors on nominal months
|
||||
elapsed from start_date's month, so clamping never shifts the cadence.
|
||||
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
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
|
||||
_DATE_FORMAT = "%Y-%m-%d"
|
||||
|
||||
#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order).
|
||||
|
||||
|
||||
class RuleType(enum.Enum):
|
||||
"""The six recurrence shapes the engine supports.
|
||||
|
||||
``storage_value`` is the string written to the ``schedule_rules``
|
||||
table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY
|
||||
and MONTHLY_WEEKDAY are distinct shapes sharing one storage value,
|
||||
disambiguated by which fields are populated.
|
||||
"""
|
||||
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY_DAY = "monthly_day"
|
||||
MONTHLY_WEEKDAY = "monthly_weekday"
|
||||
YEARLY = "yearly"
|
||||
CUSTOM_DAYS = "custom_days"
|
||||
|
||||
@property
|
||||
def storage_value(self) -> str:
|
||||
"""The schema-level rule_type string this model type maps to."""
|
||||
return {
|
||||
RuleType.DAILY: "daily",
|
||||
RuleType.WEEKLY: "weekly",
|
||||
RuleType.MONTHLY_DAY: "monthly",
|
||||
RuleType.MONTHLY_WEEKDAY: "monthly",
|
||||
RuleType.YEARLY: "yearly",
|
||||
RuleType.CUSTOM_DAYS: "custom",
|
||||
}[self]
|
||||
|
||||
|
||||
class RuleValidationError(ValueError):
|
||||
"""Raised when a ScheduleRule field combination is invalid."""
|
||||
|
||||
|
||||
def _validate_date(value: str, field_name: str) -> None:
|
||||
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
except (TypeError, ValueError):
|
||||
raise RuleValidationError(
|
||||
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() != value:
|
||||
raise RuleValidationError(
|
||||
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_weekday_set(weekday_set) -> frozenset[int]:
|
||||
"""Return a normalized frozenset or raise with a clear message.
|
||||
|
||||
Sequence entries are validated BEFORE set() coercion: a list like
|
||||
[0, False] must not collapse the boolean into 0, and unhashable
|
||||
entries must raise RuleValidationError, never a raw TypeError.
|
||||
"""
|
||||
if isinstance(weekday_set, (list, tuple)):
|
||||
for entry in weekday_set:
|
||||
# _is_plain_int: bool and int subclasses (potentially
|
||||
# unhashable) are rejected before set() coercion.
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
weekday_set = set(weekday_set)
|
||||
if not isinstance(weekday_set, (set, frozenset)):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set must be a set of weekday integers, got "
|
||||
f"{type(weekday_set).__name__}"
|
||||
)
|
||||
if not weekday_set:
|
||||
raise RuleValidationError(
|
||||
"weekday_set must not be empty for weekly/custom rules"
|
||||
)
|
||||
for entry in weekday_set:
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
if not 0 <= entry <= 6:
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
|
||||
f"got {entry!r}"
|
||||
)
|
||||
return frozenset(weekday_set)
|
||||
|
||||
|
||||
def _is_plain_int(value) -> bool:
|
||||
"""True only for real ints: bool excluded AND int subclasses too
|
||||
(an unhashable int subclass would break the frozen-hashable model)."""
|
||||
return type(value) is int
|
||||
|
||||
|
||||
def _validate_monthly_weekday(nth_weekday, weekday) -> None:
|
||||
"""Validate the MONTHLY_WEEKDAY position fields."""
|
||||
if nth_weekday is None or not _is_plain_int(nth_weekday):
|
||||
raise RuleValidationError(
|
||||
"nth_weekday must be an integer for monthly_weekday rules, "
|
||||
f"got {nth_weekday!r}"
|
||||
)
|
||||
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
|
||||
raise RuleValidationError(
|
||||
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
|
||||
)
|
||||
if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6:
|
||||
raise RuleValidationError(
|
||||
f"monthly_weekday rules require a weekday 0 (Monday)..6 "
|
||||
f"(Sunday), got {weekday!r}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleRule:
|
||||
"""An immutable, validated recurrence rule (see module docstring).
|
||||
|
||||
Construct with keyword arguments; invalid combinations raise
|
||||
:class:`RuleValidationError` at construction time. Fields map
|
||||
one-to-one onto the ``schedule_rules`` table columns, with
|
||||
``nth_weekday_weekday`` as the model-only weekday companion to
|
||||
``nth_weekday`` (serialized inside ``weekday_set`` at the DAO
|
||||
boundary).
|
||||
"""
|
||||
|
||||
rule_type: RuleType
|
||||
interval: int = 1
|
||||
weekday_set: frozenset[int] | None = None
|
||||
day_of_month: int | None = None
|
||||
nth_weekday: int | None = None
|
||||
nth_weekday_weekday: int | None = None
|
||||
month: int | None = None
|
||||
start_date: str = "1970-01-01"
|
||||
end_date: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Normalize a string rule_type into the enum; anything else
|
||||
# that is not already a RuleType is rejected.
|
||||
if isinstance(self.rule_type, str):
|
||||
# EXACT model names only: no whitespace stripping, no storage
|
||||
# aliases. The DAO maps storage strings to model fields when
|
||||
# reading rows; the model boundary stays unambiguous.
|
||||
candidates = {rt.name.lower(): rt for rt in RuleType}
|
||||
if self.rule_type in candidates:
|
||||
object.__setattr__(
|
||||
self, "rule_type", candidates[self.rule_type]
|
||||
)
|
||||
else:
|
||||
raise RuleValidationError(
|
||||
f"rule_type must be a RuleType or one of "
|
||||
f"{sorted(candidates)}, got {self.rule_type!r}"
|
||||
)
|
||||
if not isinstance(self.rule_type, RuleType):
|
||||
raise RuleValidationError(
|
||||
f"rule_type must be a RuleType or one of its string "
|
||||
f"values, got {self.rule_type!r}"
|
||||
)
|
||||
if not _is_plain_int(self.interval):
|
||||
raise RuleValidationError(
|
||||
f"interval must be an integer >= 1, got {self.interval!r}"
|
||||
)
|
||||
if self.interval < 1:
|
||||
raise RuleValidationError(
|
||||
f"interval must be >= 1, got {self.interval!r}"
|
||||
)
|
||||
|
||||
_validate_date(self.start_date, "start_date")
|
||||
if self.end_date is not None:
|
||||
_validate_date(self.end_date, "end_date")
|
||||
if self.end_date < self.start_date:
|
||||
raise RuleValidationError(
|
||||
f"end_date {self.end_date!r} must be on or after "
|
||||
f"start_date {self.start_date!r}"
|
||||
)
|
||||
|
||||
# Type-exclusive field policy, enforced for every type: build
|
||||
# the dict of present fields, then reject every field the shape
|
||||
# must not carry (weekday_set present is detectable via None vs
|
||||
# set even after normalization below).
|
||||
shape = self.rule_type
|
||||
if shape is RuleType.DAILY:
|
||||
forbidden_fields = (
|
||||
"weekday_set",
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.WEEKLY:
|
||||
forbidden_fields = (
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.CUSTOM_DAYS:
|
||||
forbidden_fields = (
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.MONTHLY_DAY:
|
||||
forbidden_fields = ("weekday_set", "nth_weekday",
|
||||
"nth_weekday_weekday", "month")
|
||||
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||
forbidden_fields = ("weekday_set", "day_of_month", "month")
|
||||
elif shape is RuleType.YEARLY:
|
||||
forbidden_fields = (
|
||||
"weekday_set",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
)
|
||||
present_forbidden = [
|
||||
name
|
||||
for name in forbidden_fields
|
||||
if getattr(self, name) is not None
|
||||
]
|
||||
if present_forbidden:
|
||||
raise RuleValidationError(
|
||||
f"{shape.value} rules must not set "
|
||||
f"{', '.join(sorted(present_forbidden))}"
|
||||
)
|
||||
|
||||
# Required fields per shape.
|
||||
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
|
||||
normalized = _validate_weekday_set(self.weekday_set)
|
||||
object.__setattr__(self, "weekday_set", normalized)
|
||||
elif shape is RuleType.MONTHLY_DAY:
|
||||
if self.day_of_month is None or not _is_plain_int(
|
||||
self.day_of_month
|
||||
):
|
||||
raise RuleValidationError(
|
||||
"monthly_day rules require day_of_month (1..31)"
|
||||
)
|
||||
if not 1 <= self.day_of_month <= 31:
|
||||
raise RuleValidationError(
|
||||
f"day_of_month must be 1..31, got {self.day_of_month!r}"
|
||||
)
|
||||
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||
_validate_monthly_weekday(
|
||||
self.nth_weekday, self.nth_weekday_weekday
|
||||
)
|
||||
elif shape is RuleType.YEARLY:
|
||||
if self.month is None or not _is_plain_int(self.month):
|
||||
raise RuleValidationError(
|
||||
"yearly rules require month (1..12)"
|
||||
)
|
||||
if not 1 <= self.month <= 12:
|
||||
raise RuleValidationError(
|
||||
f"month must be 1..12, got {self.month!r}"
|
||||
)
|
||||
if self.day_of_month is not None and not (
|
||||
_is_plain_int(self.day_of_month)
|
||||
and 1 <= self.day_of_month <= 31
|
||||
):
|
||||
raise RuleValidationError(
|
||||
f"day_of_month must be 1..31 for yearly rules, got "
|
||||
f"{self.day_of_month!r}"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Return the lossless dict form (model-side storage shape).
|
||||
|
||||
``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's
|
||||
weekday travels inside it (appended) plus
|
||||
``nth_weekday_weekday`` so the pair round-trips without
|
||||
ambiguity at the model layer.
|
||||
"""
|
||||
return {
|
||||
"rule_type": self.rule_type.name.lower(), # model name
|
||||
"interval": self.interval,
|
||||
"weekday_set": (
|
||||
sorted(self.weekday_set) if self.weekday_set is not None
|
||||
else None
|
||||
),
|
||||
"day_of_month": self.day_of_month,
|
||||
"nth_weekday": self.nth_weekday,
|
||||
"nth_weekday_weekday": self.nth_weekday_weekday,
|
||||
"month": self.month,
|
||||
"start_date": self.start_date,
|
||||
"end_date": self.end_date,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ScheduleRule":
|
||||
"""Rebuild a rule from :meth:`to_dict` output.
|
||||
|
||||
Raises :class:`RuleValidationError` on unknown keys, missing
|
||||
keys, or invalid combinations — never a raw TypeError.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise RuleValidationError(
|
||||
f"rule data must be a dict, got {type(data).__name__}"
|
||||
)
|
||||
allowed = {
|
||||
"rule_type",
|
||||
"interval",
|
||||
"weekday_set",
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
"start_date",
|
||||
"end_date",
|
||||
}
|
||||
unknown = [key for key in data if key not in allowed]
|
||||
if unknown:
|
||||
raise RuleValidationError(
|
||||
f"unknown rule fields: {sorted(unknown, key=repr)}"
|
||||
)
|
||||
if "rule_type" not in data:
|
||||
raise RuleValidationError("missing required rule field: rule_type")
|
||||
weekday_set = data.get("weekday_set")
|
||||
if isinstance(weekday_set, (list, tuple)):
|
||||
# Validate entries as plain ints BEFORE set() coercion, so a
|
||||
# list like [0, False] cannot collapse False into 0 and an
|
||||
# unhashable/int-subclass entry cannot leak a raw TypeError.
|
||||
for entry in weekday_set:
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
weekday_set = set(weekday_set)
|
||||
return cls(
|
||||
rule_type=data["rule_type"],
|
||||
interval=data.get("interval", 1),
|
||||
weekday_set=weekday_set,
|
||||
day_of_month=data.get("day_of_month"),
|
||||
nth_weekday=data.get("nth_weekday"),
|
||||
nth_weekday_weekday=data.get("nth_weekday_weekday"),
|
||||
month=data.get("month"),
|
||||
start_date=data.get("start_date", "1970-01-01"),
|
||||
end_date=data.get("end_date"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_date(value: str) -> datetime.date:
|
||||
"""Parse a strict ISO date already validated by the model."""
|
||||
return datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
|
||||
|
||||
def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int:
|
||||
"""Day offset from the rule's anchor date to ``target``.
|
||||
|
||||
The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the
|
||||
first day the rule CAN fire. A negative offset means the target
|
||||
precedes the rule's window entirely.
|
||||
"""
|
||||
return (target - _parse_date(rule.start_date)).days
|
||||
|
||||
|
||||
def _within_window(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""True when ``target`` is inside the rule's [start, end] window."""
|
||||
if target < _parse_date(rule.start_date):
|
||||
return False
|
||||
if rule.end_date is not None and target > _parse_date(rule.end_date):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _month_end(year: int, month: int) -> int:
|
||||
"""The last calendar day of the given month (leap-aware)."""
|
||||
if month == 12:
|
||||
return 31
|
||||
return (datetime.date(year, month + 1, 1)
|
||||
- datetime.timedelta(days=1)).day
|
||||
|
||||
|
||||
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""MONTHLY_DAY evaluation with the documented month-end policy.
|
||||
|
||||
Policy: in each interval-eligible month, a rule for a day-of-month
|
||||
that does not exist in that month (the 31st in April, the 30th in
|
||||
February, the 29th of February in a non-leap year) fires on the
|
||||
LAST day of that month instead of being skipped — the chore still
|
||||
happens that month, pinned to the month's end when its nominal date
|
||||
is absent.
|
||||
|
||||
The interval anchors on months ELAPSED from start_date's month: the
|
||||
rule fires when the months between the anchor month and the target's
|
||||
month form a multiple of the interval, keeping "every 2 months on
|
||||
the 15th" aligned to the anchor month even when the firing lands on
|
||||
a clamped month-end day.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
months_elapsed = (
|
||||
(target.year - anchor.year) * 12 + (target.month - anchor.month)
|
||||
)
|
||||
if months_elapsed % rule.interval != 0:
|
||||
return False
|
||||
month_end = _month_end(target.year, target.month)
|
||||
return target.day == min(rule.day_of_month, month_end)
|
||||
|
||||
|
||||
def _nth_weekday_of_month(
|
||||
year: int, month: int, weekday: int, nth: int
|
||||
) -> int | None:
|
||||
"""The day-of-month of the ``nth`` ``weekday``, or None when the
|
||||
month has no such occurrence (``nth`` 5 beyond four occurrences).
|
||||
|
||||
Positive ``nth`` counts forward (1 = first); ``-1`` counts from the
|
||||
end (last occurrence, whatever the total). Pure calendar math.
|
||||
"""
|
||||
if nth == -1:
|
||||
last = datetime.date(year, month, _month_end(year, month))
|
||||
back = (last.weekday() - weekday) % 7
|
||||
return last.day - back
|
||||
first = datetime.date(year, month, 1)
|
||||
first_offset = (weekday - first.weekday()) % 7
|
||||
day = 1 + first_offset + (nth - 1) * 7
|
||||
if day > _month_end(year, month):
|
||||
return None
|
||||
return day
|
||||
|
||||
|
||||
def _occurs_monthly_weekday(
|
||||
rule: ScheduleRule, target: datetime.date
|
||||
) -> bool:
|
||||
"""MONTHLY_WEEKDAY evaluation.
|
||||
|
||||
Fires when the target is the ``nth`` occurrence of
|
||||
``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A
|
||||
month with fewer occurrences than ``nth`` (5 on a four-occurrence
|
||||
month) does not fire — n 5 is accepted by the model but skips such
|
||||
months, matching the done-condition. The interval anchors on
|
||||
nominal months elapsed from start_date's month.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
months_elapsed = (
|
||||
(target.year - anchor.year) * 12 + (target.month - anchor.month)
|
||||
)
|
||||
if months_elapsed % rule.interval != 0:
|
||||
return False
|
||||
if target.weekday() != rule.nth_weekday_weekday:
|
||||
return False
|
||||
nth_day = _nth_weekday_of_month(
|
||||
target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday
|
||||
)
|
||||
return nth_day is not None and target.day == nth_day
|
||||
|
||||
|
||||
def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""YEARLY evaluation with the documented leap-day clamp policy.
|
||||
|
||||
Policy: in each interval-eligible year, the rule fires on
|
||||
``day_of_month`` of ``month`` when that day exists. A YEARLY rule
|
||||
for February 29th fires on FEBRUARY 28TH in a non-leap year (the
|
||||
same clamp-as-month-end policy as MONTHLY_DAY: the annual chore
|
||||
still happens every year, pinned to February's end when its
|
||||
nominal date is absent) rather than silently vanishing. A YEARLY
|
||||
rule with NO day_of_month fires on the first day of ``month``.
|
||||
The interval anchors on years elapsed from start_date's year.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
years_elapsed = target.year - anchor.year
|
||||
if years_elapsed % rule.interval != 0:
|
||||
return False
|
||||
if target.month != rule.month:
|
||||
return False
|
||||
if rule.day_of_month is None:
|
||||
# Day-less yearly rules fire on the first of the month.
|
||||
return target.day == 1
|
||||
month_end = _month_end(target.year, target.month)
|
||||
return target.day == min(rule.day_of_month, month_end)
|
||||
|
||||
|
||||
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""Return True when ``rule`` fires on ``target``.
|
||||
|
||||
Pure function: no I/O, no timezone awareness, no mutation. Dates
|
||||
are plain calendar dates (Feature 04 guardrail); the caller decides
|
||||
the timezone that produced them.
|
||||
"""
|
||||
# Dispatch FIRST: an unimplemented shape must raise loudly even for
|
||||
# dates outside the window, never silently evaluate to False.
|
||||
shape = rule.rule_type
|
||||
if shape not in (
|
||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
|
||||
):
|
||||
raise NotImplementedError(
|
||||
f"occurs_on for {shape} lands with its own engine task "
|
||||
"(Features 04 task sequence)"
|
||||
)
|
||||
if not _within_window(rule, target):
|
||||
return False
|
||||
if shape is RuleType.DAILY:
|
||||
offset = (target - _parse_date(rule.start_date)).days
|
||||
return offset % rule.interval == 0
|
||||
if shape is RuleType.MONTHLY_DAY:
|
||||
return _occurs_monthly_day(rule, target)
|
||||
if shape is RuleType.MONTHLY_WEEKDAY:
|
||||
return _occurs_monthly_weekday(rule, target)
|
||||
if shape is RuleType.YEARLY:
|
||||
return _occurs_yearly(rule, target)
|
||||
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
|
||||
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
|
||||
# parity on January 1st; anchor arithmetic does not).
|
||||
anchor = _parse_date(rule.start_date)
|
||||
if shape is RuleType.CUSTOM_DAYS:
|
||||
interval = 1
|
||||
else:
|
||||
interval = rule.interval
|
||||
week_offset = (target - anchor).days // 7
|
||||
if week_offset % interval != 0:
|
||||
return False
|
||||
return target.weekday() in rule.weekday_set
|
||||
|
||||
|
||||
def occurrences_between(
|
||||
rule: ScheduleRule, start: str, end: str
|
||||
) -> list[str]:
|
||||
"""Return every date string in [start, end] the rule fires on.
|
||||
|
||||
Inclusive on both ends. Raises RuleValidationError on malformed or
|
||||
inverted bounds; a window entirely outside [rule.start_date,
|
||||
rule.end_date] yields an empty list.
|
||||
"""
|
||||
if rule.rule_type not in (
|
||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
|
||||
):
|
||||
raise NotImplementedError(
|
||||
f"occurrences_between for {rule.rule_type} lands with its own "
|
||||
"engine task (Features 04 task sequence)"
|
||||
)
|
||||
_validate_date(start, "start")
|
||||
_validate_date(end, "end")
|
||||
start_date = _parse_date(start)
|
||||
end_date = _parse_date(end)
|
||||
if end_date < start_date:
|
||||
raise RuleValidationError(
|
||||
f"end {end!r} must be on or after start {start!r}"
|
||||
)
|
||||
rule_start = _parse_date(rule.start_date)
|
||||
if end_date < rule_start:
|
||||
return []
|
||||
# Clamp the walk to the rule's own window and the queried range.
|
||||
effective_start = max(start_date, rule_start)
|
||||
effective_end = end_date
|
||||
if rule.end_date is not None:
|
||||
effective_end = min(effective_end, _parse_date(rule.end_date))
|
||||
results: list[str] = []
|
||||
cursor = effective_start
|
||||
while cursor <= effective_end:
|
||||
if occurs_on(rule, cursor):
|
||||
results.append(cursor.isoformat())
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return results
|
||||
from .core.recurrence import * # noqa: F401,F403
|
||||
|
||||
@@ -1,411 +1,11 @@
|
||||
"""Declarative schema DDL for the NestQuest SQLite database.
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package.
|
||||
|
||||
Every v1 table is declared here as plain SQL statements so later schema
|
||||
tasks can append their own DDL to :data:`SCHEMA_V1_STATEMENTS` without
|
||||
touching this module's tables. All statements use
|
||||
``CREATE TABLE IF NOT EXISTS`` so application is idempotent; the versioned
|
||||
migration runner is a separate later concern.
|
||||
|
||||
Timestamp policy: all timestamps are UTC ISO-8601 strings, set by the
|
||||
caller/DAO rather than a DB default, per the feature guardrails. No
|
||||
``length(created_at) = 25`` CHECK is added: the exact string shape
|
||||
produced by callers is not fixed yet (``datetime.isoformat()`` may
|
||||
include microseconds, changing the length), so enforcing a fixed length
|
||||
here would reject valid values. Only non-null is enforced for now.
|
||||
|
||||
Date policy: ``start_date``/``end_date`` are ISO date strings (YYYY-MM-DD,
|
||||
no time component) compared lexically by the ``end_date >= start_date``
|
||||
CHECK; due_date/due_time string shapes are caller-validated by the rule
|
||||
engine and DAO layers, not by DDL.
|
||||
|
||||
``weekday_set`` format: comma-separated weekday integers (CSV), 0 =
|
||||
Monday .. 6 = Sunday, e.g. ``'0,2,4'`` for Mon/Wed/Fri. Chosen over JSON
|
||||
because it sorts, compares and parses trivially in SQL. The format is
|
||||
enforced at the DB level: a CHECK matches ``weekday_set`` against GLOB
|
||||
patterns for element counts 1..7 of single digits 0-6 (GLOB has no
|
||||
repetition quantifier, so the element count is enumerated; more than 7
|
||||
elements is impossible for a weekday set). ``''``, ``'x'``, ``'7'`` and
|
||||
``'0,x'`` are all rejected; NULL stays allowed for non-weekly rules.
|
||||
This keeps the schema layer storage-only — the DAO layer (tasks
|
||||
8c72300c+) does not need its own validator, it can rely on the CHECK.
|
||||
|
||||
Type affinity: SQLite applies column affinity on insert, but CHECKs
|
||||
still observe text and fractional values ('abc' stays text, '1.5'
|
||||
becomes REAL), so every INTEGER column carrying rule semantics carries
|
||||
a ``typeof(...) = 'integer'`` guard in its CHECK.
|
||||
|
||||
Rule-type coherence: two CHECKs enforce that ``weekly`` carries a
|
||||
non-null ``weekday_set``, ``monthly`` carries a ``day_of_month`` or an
|
||||
``nth_weekday``, ``yearly`` carries a ``month``, and ``daily``/``custom``
|
||||
carry none of the month-specific fields. Relaxed deliberately: the DDL
|
||||
does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks"
|
||||
rule or a monthly rule could later be pinned to a month) nor extra
|
||||
fields on weekly/monthly/yearly rules generally, so the CHECKs only
|
||||
reject what the rule engine definitively cannot express now.
|
||||
|
||||
Presence pattern format: ``presence_schedules.pattern`` encodes the
|
||||
whole N-week repeating cycle in one TEXT column as pipe-separated
|
||||
segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
|
||||
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
|
||||
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
|
||||
week 1. An empty segment means the child is absent every day of that
|
||||
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means
|
||||
absent every day of every week, which is distinct from having no
|
||||
schedule row at all (a child with no schedule is present every day, per
|
||||
the feature guardrails). Chosen over JSON or per-week rows to stay
|
||||
consistent with the CSV ``weekday_set`` convention and fully checkable
|
||||
by plain CHECKs. DB-level validation is pure CHECK, no triggers or
|
||||
custom functions: char-class GLOBs reject every character outside
|
||||
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
|
||||
(double comma, comma next to a pipe, leading/trailing comma) and
|
||||
two-digit runs; a further GLOB rejects contiguous runs of 8+ comma
|
||||
separated digits, which caps a segment at 7 elements — the GLOB cannot
|
||||
match across a ``|``, so cross-segment boundaries stay safe; and the
|
||||
segment count is pinned to ``cycle_length_weeks`` by
|
||||
``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per
|
||||
weekday_set this is not full CSV-set normalisation (duplicates like
|
||||
``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``);
|
||||
the engine treats the segment as a set. A
|
||||
one-row-per-``week_index`` normalization was considered and rejected:
|
||||
the flat single-column encoding keeps the done-condition's table shape
|
||||
and proved fully checkable with plain CHECKs.
|
||||
|
||||
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
|
||||
common case, and a small cap keeps anchor arithmetic and the encoded
|
||||
pattern human-readable. Revisit the cap — and re-run the pattern
|
||||
validation matrix in tests — if a longer cycle is ever needed.
|
||||
``anchor_date`` is an ISO date string pinning the cycle: the week index
|
||||
of a date is (days since anchor) // 7 mod ``cycle_length_weeks``,
|
||||
computed by the presence engine (Feature 05) from anchor-date
|
||||
arithmetic, never ISO week parity. Like other date columns it carries
|
||||
no shape CHECK, matching the established date policy.
|
||||
|
||||
Presence overrides: ``presence_overrides.end_date`` is NOT NULL,
|
||||
unlike ``schedule_rules.end_date`` — an override is always a concrete
|
||||
date range; a single-day override stores the same date in both columns.
|
||||
``is_present`` 0/1 marks the child absent/present for the whole range;
|
||||
``note`` is optional free text.
|
||||
|
||||
Multi-assignee definitions (D-008): ``quest_definitions`` carries no
|
||||
``child_id`` column. Assignment lives in
|
||||
``quest_definition_assignees``, one row per (definition, child) with a
|
||||
composite primary key and foreign keys to the definition and the child,
|
||||
so "brush teeth" is one definition covering three children. The
|
||||
migration runner rebuilds a pre-D-008 table into this shape and copies
|
||||
its single-assignee column across. The composite primary key makes
|
||||
duplicate assignment impossible at the storage layer.
|
||||
|
||||
Quest windows (D-008): ``quest_definition_windows`` declares which day
|
||||
windows a definition spans — one row per (definition, window), the
|
||||
composite primary key making a window idempotent per definition.
|
||||
``window`` is CHECK-constrained to the three spellings ``const`` owns;
|
||||
``due_time`` optionally pins a due time inside the window and is
|
||||
caller-validated (strict HH:MM) by the DAO layer, matching the date
|
||||
policy above. The window clock ranges live in ``const.py``, not here:
|
||||
this layer stores what the caller declares and never interprets clocks.
|
||||
|
||||
Quest instances: ``quest_instances`` deliberately carries NO completion
|
||||
status column. An instance's current state derives from the latest row
|
||||
in ``completion_events`` (Feature 08), so materialization (Feature 07)
|
||||
can insert instances without knowing anything about completion and the
|
||||
append-only log stays the single source of truth. ``definition_id``,
|
||||
``child_id``, ``due_date`` and ``window`` snapshot the
|
||||
materialization-time facts ("why this instance exists"): assignment
|
||||
edits and rule edits change future instances only and never rewrite
|
||||
these rows. ``window`` records which declared day window produced the
|
||||
instance (D-008), CHECK-constrained to the same three spellings
|
||||
``const`` owns. ``due_time`` snapshots the window's optional due time
|
||||
at generation for the same reason — the instance keeps its
|
||||
generation-time value even if the definition is edited later.
|
||||
``generated_at`` is a caller-set UTC ISO-8601 timestamp per the module
|
||||
timestamp policy (no DB default, so a generation batch stamps one
|
||||
coherent time).
|
||||
|
||||
Uniqueness: the (definition_id, child_id, due_date, window) tuple is
|
||||
declared as a table-level ``UNIQUE`` constraint rather than a separate
|
||||
``CREATE UNIQUE INDEX`` statement. SQLite materializes it as an
|
||||
implicit unique index (``sqlite_autoindex_quest_instances_1``) — the
|
||||
required "unique index prevents duplicate instances" — and the
|
||||
constraint form cannot be silently dropped the way a standalone index
|
||||
can, so the duplicate door stays shut. Idempotent materialization keys
|
||||
on exactly this tuple: the twice-daily case (one definition, two
|
||||
windows, one child, one date) yields two rows and the shared case (one
|
||||
definition, three assignees, one window) yields three. No secondary
|
||||
indexes beyond it are declared yet: history and board queries (Features
|
||||
08/10/13) should justify their indexes as a later migration once their
|
||||
query shapes exist.
|
||||
|
||||
No ``ON DELETE`` clauses are declared on any foreign key in this
|
||||
module: children and definitions are deactivated, never deleted, so a
|
||||
deletion that would orphan instances or events is a bug and must fail
|
||||
loudly rather than cascade.
|
||||
|
||||
Completion events: ``completion_events`` is strictly append-only. That
|
||||
property is enforced at the DAO layer — the completion-events DAO
|
||||
exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT
|
||||
by database triggers, matching the module's no-trigger policy: a RAISE
|
||||
trigger would be the one exception to "pure CHECK, no triggers",
|
||||
complicate the later migration runner, and still prove nothing (any
|
||||
writer can run raw SQL). ``event_type`` is constrained to
|
||||
``completed``/``uncompleted``; a ``missed`` row is intentionally not
|
||||
representable — missed is a derived state announced on the HA event bus
|
||||
(Feature 10), never an appended event.
|
||||
|
||||
``child_id`` is deliberately denormalized (it is also derivable via
|
||||
``quest_instances``): history reporting (Feature 13) filters and rates
|
||||
per child directly against the log, and the log should stay
|
||||
self-describing without joins.
|
||||
|
||||
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
||||
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
||||
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
||||
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
|
||||
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
|
||||
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
|
||||
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
|
||||
Other actor sources are rejected; if an unattended system actor is
|
||||
ever needed, that is a schema migration, not a silent widening.
|
||||
|
||||
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
||||
``completed`` events — at completion time the due date is known and the
|
||||
flag is decidable. For ``uncompleted`` events the flag is optional:
|
||||
Feature 08 decides whether a reversal carries the reversed completion's
|
||||
on-time flag or NULL, and the DDL deliberately accepts both rather than
|
||||
guessing.
|
||||
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 .db import NestQuestDatabase
|
||||
|
||||
#: GLOB patterns matching a valid ``weekday_set`` CSV: one GLOB per
|
||||
#: possible element count 1..7 (a weekday set cannot exceed 7 elements).
|
||||
#: Each element is a single digit 0-6, separated by a literal comma.
|
||||
#: GLOB has no repetition quantifier, so the element count is enumerated.
|
||||
#: ``''`` and NULL are deliberately NOT matched: NULL is allowed for
|
||||
#: non-weekly rules via a separate ``IS NULL`` arm, and '' is invalid
|
||||
#: because weekly rules need at least one weekday.
|
||||
WEEKDAY_SET_GLOB_PATTERNS: list[str] = [
|
||||
"[0-6]",
|
||||
"[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
]
|
||||
|
||||
SCHEMA_V1_CHILDREN_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS children (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
display_name TEXT NOT NULL,
|
||||
colour TEXT,
|
||||
avatar_ref TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (typeof(sort_order) = 'integer'),
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_ADMIN_USERS_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
ha_user_id TEXT PRIMARY KEY NOT NULL,
|
||||
added_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
_WEEKDAY_SET_CHECK = " OR ".join(
|
||||
f"weekday_set GLOB '{pattern}'" for pattern in WEEKDAY_SET_GLOB_PATTERNS
|
||||
)
|
||||
|
||||
SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_type TEXT NOT NULL CHECK (rule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')),
|
||||
interval INTEGER NOT NULL DEFAULT 1 CHECK (typeof(interval) = 'integer' AND interval >= 1),
|
||||
weekday_set TEXT CHECK (weekday_set IS NULL OR ({_WEEKDAY_SET_CHECK})),
|
||||
day_of_month INTEGER CHECK (day_of_month IS NULL OR (typeof(day_of_month) = 'integer' AND day_of_month >= 1 AND day_of_month <= 31)),
|
||||
nth_weekday INTEGER CHECK (nth_weekday IS NULL OR (typeof(nth_weekday) = 'integer' AND nth_weekday IN (-1, 1, 2, 3, 4, 5))),
|
||||
month INTEGER CHECK (month IS NULL OR (typeof(month) = 'integer' AND month >= 1 AND month <= 12)),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT,
|
||||
CHECK (end_date IS NULL OR end_date >= start_date),
|
||||
CHECK (
|
||||
(rule_type = 'weekly' AND weekday_set IS NOT NULL)
|
||||
OR (rule_type = 'monthly' AND (day_of_month IS NOT NULL OR nth_weekday IS NOT NULL))
|
||||
OR (rule_type = 'yearly' AND month IS NOT NULL)
|
||||
OR (rule_type IN ('daily', 'custom') AND day_of_month IS NULL AND nth_weekday IS NULL AND month IS NULL)
|
||||
)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
#: Column body of ``quest_definitions``, shared between the v1 DDL
|
||||
#: below and the multi-assignee rebuild migration, which must recreate
|
||||
#: the table in exactly this shape (D-008: one definition, many
|
||||
#: assignees via ``quest_definition_assignees`` — no ``child_id``
|
||||
#: column on the definition itself).
|
||||
QUEST_DEFINITIONS_TABLE_SQL = """(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
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
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definitions {QUEST_DEFINITIONS_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definition_assignees (
|
||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
PRIMARY KEY (definition_id, child_id)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS quest_definition_windows (
|
||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
|
||||
due_time TEXT,
|
||||
PRIMARY KEY (definition_id, window)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS presence_schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
|
||||
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
|
||||
anchor_date TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL CHECK (
|
||||
NOT pattern GLOB '*[^0-6,|]*'
|
||||
AND NOT pattern GLOB '*[0-6][0-6]*'
|
||||
AND NOT pattern GLOB '*,,*'
|
||||
AND NOT pattern GLOB '*,|*'
|
||||
AND NOT pattern GLOB '*|,*'
|
||||
AND NOT pattern GLOB ',*'
|
||||
AND NOT pattern GLOB '*,'
|
||||
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
|
||||
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
|
||||
)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS presence_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)),
|
||||
note TEXT,
|
||||
CHECK (end_date >= start_date)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
#: Column body of ``quest_instances``, shared between the v1 DDL
|
||||
#: below and the instance-key rebuild migration, which must recreate
|
||||
#: the table in exactly this shape (D-008: the instance key is
|
||||
#: (definition_id, child_id, due_date, window) — one instance per
|
||||
#: assigned child per declared window per firing date).
|
||||
QUEST_INSTANCES_TABLE_SQL = """(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
|
||||
due_date TEXT NOT NULL,
|
||||
due_time TEXT,
|
||||
generated_at TEXT NOT NULL,
|
||||
UNIQUE (definition_id, child_id, due_date, window)
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS quest_instances {QUEST_INSTANCES_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
#: Column body of ``completion_events``, shared between the v1 DDL
|
||||
#: below and the actor-child rebuild migration, which must recreate the
|
||||
#: table in exactly this shape (D-008: ``actor_child_id`` records which
|
||||
#: panel profile was tapped — set for panel events, NULL for admin
|
||||
#: ones; legacy panel rows backfill to ``child_id``, the child they
|
||||
#: always were).
|
||||
COMPLETION_EVENTS_TABLE_SQL = """(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
||||
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
||||
actor_user_id TEXT,
|
||||
actor_child_id INTEGER REFERENCES children(id),
|
||||
occurred_at TEXT NOT NULL,
|
||||
was_on_time INTEGER CHECK (
|
||||
was_on_time IS NULL
|
||||
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
||||
),
|
||||
CHECK (
|
||||
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
||||
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
||||
),
|
||||
CHECK (
|
||||
(actor_source = 'user' AND actor_child_id IS NULL)
|
||||
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
|
||||
),
|
||||
CHECK (
|
||||
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
||||
OR (event_type = 'uncompleted')
|
||||
)
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_STATEMENTS: list[str] = [
|
||||
*SCHEMA_V1_CHILDREN_DDL,
|
||||
*SCHEMA_V1_ADMIN_USERS_DDL,
|
||||
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
|
||||
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
*SCHEMA_V1_QUEST_INSTANCES_DDL,
|
||||
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
||||
]
|
||||
|
||||
|
||||
async def async_apply_ddl(
|
||||
database: NestQuestDatabase, statements: list[str]
|
||||
) -> None:
|
||||
"""Apply each DDL statement inside one transaction on ``database``.
|
||||
|
||||
Idempotent: every statement is ``CREATE TABLE IF NOT EXISTS``, so
|
||||
applying the same list twice leaves the schema and its rows intact.
|
||||
"""
|
||||
async with database.transaction():
|
||||
for sql in statements:
|
||||
await database.execute(sql)
|
||||
from .core.schema import * # noqa: F401,F403
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Per-child daily count and percentage sensors (Feature 10, task 2).
|
||||
|
||||
Naming contract (design/ENTITIES-AND-SERVICES.md §1): the design table
|
||||
documents the entity_id shape ``sensor.nestquest_<child>_quests_due_today``.
|
||||
Home Assistant derives entity_id from the entity NAME and exact
|
||||
entity_id strings are not directly controllable, so names are spelled
|
||||
to slug into the documented shape — ``NestQuest Ada quests due today``
|
||||
slugs to ``sensor.nestquest_ada_quests_due_today`` — while the
|
||||
registry identity (``unique_id``) IS exact: built from the child's
|
||||
database id, never the display name, so a rename never orphans an
|
||||
entity's history. The design table is the visual contract; this
|
||||
module spells names so HA's default slugging produces it.
|
||||
|
||||
Every sensor reads ONLY ``coordinator.data`` (one
|
||||
:class:`~.coordinator.ChildDaySnapshot` per active child); no entity
|
||||
queries the database, so one refresh updates the whole board
|
||||
coherently. The due-today sensor additionally carries the child's
|
||||
day payload as attributes: ``instances`` is the panel shape of
|
||||
ENTITIES-AND-SERVICES.md §1 (state ``open``/``completed``) OMITTING
|
||||
missed instances, and ``admin_instances`` includes them (D-009).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
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 .const import DOMAIN
|
||||
from .coordinator import (
|
||||
ChildDaySnapshot,
|
||||
NestQuestCoordinator,
|
||||
QuestInstanceView,
|
||||
)
|
||||
from .core.snapshot import instance_payload
|
||||
|
||||
#: 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
|
||||
|
||||
|
||||
def _state_within_limit(state: Any) -> Any:
|
||||
"""Return ``state`` unchanged, refusing strings over HA's limit.
|
||||
|
||||
Numeric states pass through untouched; a string state longer than
|
||||
:data:`MAX_STATE_LENGTH` fails fast with ValueError at the property
|
||||
read — before HA's state machine rejects the write.
|
||||
"""
|
||||
if isinstance(state, str) and len(state) > MAX_STATE_LENGTH:
|
||||
raise ValueError(
|
||||
f"state exceeds Home Assistant's {MAX_STATE_LENGTH}-character "
|
||||
f"limit ({len(state)} characters)"
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
#: 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).
|
||||
NEXT_QUEST_NONE_STATE = "none"
|
||||
|
||||
|
||||
def _next_open_quest(
|
||||
child: ChildDaySnapshot,
|
||||
) -> QuestInstanceView | None:
|
||||
"""Return the child's earliest open quest from today's snapshot.
|
||||
|
||||
The snapshot carries TODAY's instances, so "next" is the earliest
|
||||
open instance by (due_date, due_time), with ``due_time`` ordering
|
||||
ahead of ``None`` (a window with no due time comes first only
|
||||
within the same date — the materializer snapshots each window's
|
||||
due_time, so a missing one means the window is unscheduled).
|
||||
Missed and completed instances never qualify.
|
||||
"""
|
||||
open_views = [
|
||||
view
|
||||
for view in child.instances
|
||||
if view.state == "open"
|
||||
]
|
||||
if not open_views:
|
||||
return None
|
||||
return min(
|
||||
open_views,
|
||||
key=lambda view: (
|
||||
view.due_date,
|
||||
view.due_time if view.due_time is not None else "",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
|
||||
"""Base for the per-child day sensors.
|
||||
|
||||
Registry identity is built from the child's database id and never
|
||||
changes; the DISPLAYED name and device label are derived from the
|
||||
child's CURRENT snapshot on every read, so a rename through
|
||||
``manage_child`` propagates on the next refresh without orphaning
|
||||
the entity's history. A child absent from the latest snapshot (a
|
||||
deactivation; removal is a later task's scope) yields ``None``
|
||||
states rather than stale counts.
|
||||
"""
|
||||
|
||||
#: Per-subclass label appended to the name (e.g. ``quests due
|
||||
#: today``), set as a class attribute so ``name`` composes from the
|
||||
#: current snapshot.
|
||||
_name_suffix: str = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._child_id = child.child_id
|
||||
self._fallback_name = child.child_name
|
||||
self._attr_unique_id = None # set by the subclass
|
||||
|
||||
@property
|
||||
def _child(self) -> ChildDaySnapshot | None:
|
||||
"""Return the child's current day snapshot, or None."""
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
for child in data.children:
|
||||
if child.child_id == self._child_id:
|
||||
return child
|
||||
return None
|
||||
|
||||
def _displayed_child_name(self) -> str:
|
||||
child = self._child
|
||||
return child.child_name if child is not None else self._fallback_name
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
return f"NestQuest {self._displayed_child_name()} {self._name_suffix}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"child_{self._child_id}")},
|
||||
name=f"NestQuest {self._displayed_child_name()}",
|
||||
manufacturer="NestQuest",
|
||||
)
|
||||
|
||||
|
||||
class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor):
|
||||
"""How many quests the child has due today, with the day payload."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_name_suffix = "quests due today"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_quests_due_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.due_today)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return {
|
||||
"instances": instance_payload(
|
||||
child.instances, include_missed=False
|
||||
),
|
||||
"admin_instances": instance_payload(
|
||||
child.instances, include_missed=True
|
||||
),
|
||||
"child_id": child.child_id,
|
||||
"child_name": child.child_name,
|
||||
"present": child.present,
|
||||
}
|
||||
|
||||
|
||||
class NestQuestQuestsCompletedTodaySensor(_NestQuestChildDaySensor):
|
||||
"""How many of the child's quests were completed today."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_name_suffix = "quests completed today"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_quests_completed_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.completed_today)
|
||||
|
||||
|
||||
class NestQuestQuestsRemainingTodaySensor(_NestQuestChildDaySensor):
|
||||
"""How many of the child's quests are still owed today."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_name_suffix = "quests remaining today"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_quests_remaining_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.remaining_today)
|
||||
|
||||
|
||||
class NestQuestCompletionPctTodaySensor(_NestQuestChildDaySensor):
|
||||
"""The child's completion percentage for today (100 on a zero-quest day)."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = "%"
|
||||
_name_suffix = "completion pct today"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_completion_pct_today"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
return _state_within_limit(child.completion_pct)
|
||||
|
||||
|
||||
class NestQuestNextQuestSensor(_NestQuestChildDaySensor):
|
||||
"""The child's next open quest today (title in the state).
|
||||
|
||||
State is the quest TITLE, truncated to HA's 255-character state
|
||||
limit (attributes always carry the full title); when the child
|
||||
owes nothing else today the state is the documented sentinel
|
||||
``none`` (NEXT_QUEST_NONE_STATE), never an empty string.
|
||||
"""
|
||||
|
||||
_attr_state_class = None # a label, not a measurement
|
||||
_name_suffix = "next quest"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NestQuestCoordinator,
|
||||
child: ChildDaySnapshot,
|
||||
) -> None:
|
||||
super().__init__(coordinator, child)
|
||||
self._attr_unique_id = (
|
||||
f"{DOMAIN}_child_{child.child_id}_next_quest"
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
quest = _next_open_quest(child)
|
||||
if quest is None:
|
||||
return NEXT_QUEST_NONE_STATE
|
||||
return quest.title[:MAX_STATE_LENGTH]
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
child = self._child
|
||||
if child is None:
|
||||
return None
|
||||
quest = _next_open_quest(child)
|
||||
if quest is None:
|
||||
return {"child_id": child.child_id, "child_name": child.child_name}
|
||||
return {
|
||||
"instance_id": quest.instance_id,
|
||||
"definition_id": quest.definition_id,
|
||||
"child_id": quest.child_id,
|
||||
"child_name": child.child_name,
|
||||
"title": quest.title,
|
||||
"icon": quest.icon,
|
||||
"window": quest.window,
|
||||
"due_date": quest.due_date,
|
||||
"due_time": quest.due_time,
|
||||
"overdue": quest.overdue,
|
||||
}
|
||||
|
||||
|
||||
def _child_day_sensors(
|
||||
coordinator: NestQuestCoordinator, child: ChildDaySnapshot
|
||||
) -> tuple[_NestQuestChildDaySensor, ...]:
|
||||
"""Build the per-child day sensors for one active child."""
|
||||
return (
|
||||
NestQuestQuestsDueTodaySensor(coordinator, child),
|
||||
NestQuestQuestsCompletedTodaySensor(coordinator, child),
|
||||
NestQuestQuestsRemainingTodaySensor(coordinator, child),
|
||||
NestQuestCompletionPctTodaySensor(coordinator, child),
|
||||
NestQuestNextQuestSensor(coordinator, child),
|
||||
)
|
||||
|
||||
|
||||
class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
|
||||
"""Base for the household-level rollup sensors.
|
||||
|
||||
Aggregates the ACTIVE children's snapshots only (the snapshot
|
||||
already excludes inactive children), matching the per-child
|
||||
sensors exactly — a household number is always the sum of the
|
||||
per-child numbers. Grouped under one household device so the
|
||||
three household sensors render together.
|
||||
"""
|
||||
|
||||
_attr_name: str
|
||||
|
||||
def __init__(self, coordinator: NestQuestCoordinator) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, "household")},
|
||||
name="NestQuest Household",
|
||||
manufacturer="NestQuest",
|
||||
)
|
||||
|
||||
|
||||
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
|
||||
"""Total quests due today across active children."""
|
||||
|
||||
_attr_name = "NestQuest household quests due today"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_unique_id = f"{DOMAIN}_household_quests_due_today"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return _state_within_limit(
|
||||
sum(child.due_today for child in data.children)
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return {
|
||||
"children": {
|
||||
str(child.child_id): child.due_today
|
||||
for child in data.children
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NestQuestHouseholdCompletedTodaySensor(_NestQuestHouseholdSensor):
|
||||
"""Total quests completed today across active children."""
|
||||
|
||||
_attr_name = "NestQuest household quests completed today"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_unique_id = f"{DOMAIN}_household_quests_completed_today"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return _state_within_limit(
|
||||
sum(child.completed_today for child in data.children)
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return {
|
||||
"children": {
|
||||
str(child.child_id): child.completed_today
|
||||
for child in data.children
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NestQuestCycleDaySensor(_NestQuestHouseholdSensor):
|
||||
"""Cycle day N of the custody cycle (0 when nobody is scheduled).
|
||||
|
||||
Computed by the coordinator from the first active child with a
|
||||
presence schedule, anchor-date arithmetic only (D-004 — never ISO
|
||||
week parity), so a 53-week year cannot silently invert it.
|
||||
"""
|
||||
|
||||
_attr_name = "NestQuest cycle day"
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_unique_id = f"{DOMAIN}_cycle_day"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return _state_within_limit(data.cycle_day)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
return {"cycle_day": data.cycle_day, "today": data.today_iso}
|
||||
|
||||
|
||||
def _household_sensors(
|
||||
coordinator: NestQuestCoordinator,
|
||||
) -> tuple[_NestQuestHouseholdSensor, ...]:
|
||||
"""Build the three household-level sensors (one set per entry)."""
|
||||
return (
|
||||
NestQuestHouseholdDueTodaySensor(coordinator),
|
||||
NestQuestHouseholdCompletedTodaySensor(coordinator),
|
||||
NestQuestCycleDaySensor(coordinator),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[..., None],
|
||||
) -> None:
|
||||
"""Create the five day sensors per active child.
|
||||
|
||||
Entities are created for the snapshot's children at platform
|
||||
setup; a coordinator listener adds sensors for children that
|
||||
first appear on a later refresh. Deactivated children are NOT
|
||||
removed here — their sensors hold ``None`` states until the
|
||||
entity-removal task lands.
|
||||
"""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
known_child_ids: set[int] = set()
|
||||
|
||||
def _add_new_children(children: Iterable[ChildDaySnapshot]) -> None:
|
||||
new_children = [
|
||||
child
|
||||
for child in children
|
||||
if child.child_id not in known_child_ids
|
||||
]
|
||||
if not new_children:
|
||||
return
|
||||
known_child_ids.update(child.child_id for child in new_children)
|
||||
async_add_entities(
|
||||
[
|
||||
sensor
|
||||
for child in new_children
|
||||
for sensor in _child_day_sensors(coordinator, child)
|
||||
]
|
||||
)
|
||||
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
# The household rollups are child-independent: created exactly
|
||||
# once at platform setup, they aggregate whatever the snapshot
|
||||
# carries (empty included — an empty household is a valid 0).
|
||||
async_add_entities(_household_sensors(coordinator))
|
||||
|
||||
def _handle_coordinator_update() -> None:
|
||||
data = coordinator.data
|
||||
if data is not None:
|
||||
_add_new_children(data.children)
|
||||
|
||||
coordinator.async_add_listener(_handle_coordinator_update)
|
||||
@@ -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
|
||||
@@ -0,0 +1,629 @@
|
||||
"""Domain-global NestQuest service schemas and handlers (Feature 09).
|
||||
|
||||
Registration is domain-scoped and happens once (see
|
||||
:func:`async_register_services`); handlers resolve a live database at
|
||||
call time through the injected ``find_runtime`` callable. Permission
|
||||
checks are Feature 09 later tasks — this module only validates payload
|
||||
shape and forwards to the Feature 03/06/08 business layers (and the
|
||||
presence DAOs). ``export_history_csv`` is registered with a schema but
|
||||
raises until Feature 13. ``regenerate`` is supplied by the caller so
|
||||
the Feature 07 handler stays the one in ``__init__``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import children as children_layer
|
||||
from . import completion as completion_layer
|
||||
from . import quest_definitions as quest_definitions_layer
|
||||
from .const import (
|
||||
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:
|
||||
"""Reject bools and non-ints; voluptuous ``int`` would accept True."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise vol.Invalid(f"expected int, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _strict_bool(value: object) -> bool:
|
||||
"""Reject ints and other truthy values; require a real bool."""
|
||||
if type(value) is not bool:
|
||||
raise vol.Invalid(f"expected bool, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _weekday_list(value: object) -> list[int]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
|
||||
return [_strict_int(item) for item in value]
|
||||
|
||||
|
||||
def _presence_pattern(value: object) -> dict[int, list[int]]:
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
|
||||
pattern: dict[int, list[int]] = {}
|
||||
for week, days in value.items():
|
||||
if isinstance(week, str):
|
||||
try:
|
||||
week_index = int(week)
|
||||
except ValueError as err:
|
||||
raise vol.Invalid(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
) from err
|
||||
if str(week_index) != week:
|
||||
raise vol.Invalid(
|
||||
f"pattern week index must be an integer, got {week!r}"
|
||||
)
|
||||
else:
|
||||
week_index = _strict_int(week)
|
||||
pattern[week_index] = _weekday_list(days)
|
||||
return pattern
|
||||
|
||||
|
||||
def _window_entry(value: object) -> str | tuple[str, str | None]:
|
||||
if isinstance(value, str):
|
||||
if value not in QUEST_WINDOWS:
|
||||
raise vol.Invalid(f"unknown window {value!r}")
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
parsed = vol.Schema(
|
||||
{
|
||||
vol.Required("window"): vol.In(QUEST_WINDOWS),
|
||||
vol.Optional("due_time"): str,
|
||||
}
|
||||
)(value)
|
||||
due_time = parsed.get("due_time")
|
||||
if due_time is None:
|
||||
return parsed["window"]
|
||||
return (parsed["window"], due_time)
|
||||
raise vol.Invalid(
|
||||
f"windows entry must be a window name or object, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _windows(value: object) -> list[str | tuple[str, str | None]]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"windows must be a list, got {value!r}")
|
||||
if not value:
|
||||
raise vol.Invalid("windows must not be empty")
|
||||
return [_window_entry(entry) for entry in value]
|
||||
|
||||
|
||||
def _int_list(value: object) -> list[int]:
|
||||
if type(value) is not list:
|
||||
raise vol.Invalid(f"expected a list of ints, got {value!r}")
|
||||
return [_strict_int(item) for item in value]
|
||||
|
||||
|
||||
def _rule_dict(value: object) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise vol.Invalid(f"rule must be a dict, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA_COMPLETE_QUEST = vol.Schema(
|
||||
{
|
||||
vol.Required("instance_id"): _strict_int,
|
||||
vol.Required("actor"): vol.In([ACTOR_PANEL, ACTOR_USER]),
|
||||
vol.Optional("actor_child_id"): _strict_int,
|
||||
vol.Optional("actor_user_id"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
|
||||
|
||||
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
|
||||
{
|
||||
vol.Required("title"): str,
|
||||
vol.Required("rule"): _rule_dict,
|
||||
vol.Required("assignee_child_ids"): vol.All(
|
||||
_int_list, vol.Length(min=1)
|
||||
),
|
||||
vol.Required("windows"): _windows,
|
||||
vol.Optional("description"): str,
|
||||
vol.Optional("icon"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
|
||||
{
|
||||
vol.Required("definition_id"): _strict_int,
|
||||
vol.Optional("title"): str,
|
||||
vol.Optional("description"): vol.Any(str, None),
|
||||
vol.Optional("icon"): vol.Any(str, None),
|
||||
vol.Optional("rule"): _rule_dict,
|
||||
vol.Optional("windows"): _windows,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
|
||||
{
|
||||
vol.Required("definition_id"): _strict_int,
|
||||
vol.Required("is_active"): _strict_bool,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
|
||||
{
|
||||
vol.Required("child_id"): _strict_int,
|
||||
vol.Required("cycle_length_weeks"): _strict_int,
|
||||
vol.Required("anchor_date"): str,
|
||||
vol.Required("pattern"): _presence_pattern,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
|
||||
{
|
||||
vol.Required("child_id"): _strict_int,
|
||||
vol.Required("start_date"): str,
|
||||
vol.Required("end_date"): str,
|
||||
vol.Required("is_present"): _strict_bool,
|
||||
vol.Optional("note"): str,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
|
||||
{
|
||||
vol.Required("override_id"): _strict_int,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
|
||||
|
||||
SCHEMA_MANAGE_CHILD = vol.Schema(
|
||||
{
|
||||
vol.Required("action"): vol.In(
|
||||
[
|
||||
MANAGE_CHILD_CREATE,
|
||||
MANAGE_CHILD_EDIT,
|
||||
MANAGE_CHILD_SET_ACTIVE,
|
||||
MANAGE_CHILD_REORDER,
|
||||
]
|
||||
),
|
||||
vol.Optional("child_id"): _strict_int,
|
||||
vol.Optional("display_name"): str,
|
||||
vol.Optional("colour"): str,
|
||||
vol.Optional("avatar_ref"): str,
|
||||
vol.Optional("sort_order"): _strict_int,
|
||||
vol.Optional("is_active"): _strict_bool,
|
||||
vol.Optional("ordered_ids"): _int_list,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
||||
runtime = find_runtime(hass)
|
||||
if runtime is None:
|
||||
raise HomeAssistantError(
|
||||
"NestQuest has no live database; cannot run this service"
|
||||
)
|
||||
return runtime
|
||||
|
||||
|
||||
async def _refresh_entities(runtime: Any) -> None:
|
||||
"""Force an immediate coordinator refresh after a service mutation.
|
||||
|
||||
Only the SERVICE paths call this — the scheduled materialize and
|
||||
rollover internals are direct function calls and never widen this
|
||||
gate — so entities reflect a completion the moment the service
|
||||
call returns instead of waiting for the poll interval.
|
||||
"""
|
||||
coordinator = getattr(runtime, "coordinator", None)
|
||||
if coordinator is not 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:
|
||||
actor_child_id = call.data.get("actor_child_id")
|
||||
if actor_child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"actor_child_id is required when actor is panel"
|
||||
)
|
||||
return {
|
||||
"actor_source": ACTOR_PANEL,
|
||||
"actor_child_id": actor_child_id,
|
||||
"actor_user_id": None,
|
||||
}
|
||||
context = getattr(call, "context", None)
|
||||
user_id = getattr(context, "user_id", None) if context is not None else None
|
||||
if not user_id:
|
||||
user_id = call.data.get("actor_user_id")
|
||||
return {
|
||||
"actor_source": ACTOR_USER,
|
||||
"actor_user_id": user_id,
|
||||
"actor_child_id": None,
|
||||
}
|
||||
|
||||
|
||||
def _with_errors(handler: ServiceHandler) -> ServiceHandler:
|
||||
async def _wrapped(call: Any) -> None:
|
||||
try:
|
||||
await handler(call)
|
||||
except HomeAssistantError:
|
||||
raise
|
||||
except ValueError as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
return _wrapped
|
||||
|
||||
|
||||
def _complete_quest(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
instance_id = call.data["instance_id"]
|
||||
# ``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 result.appended:
|
||||
await fire_quest_completed(
|
||||
hass,
|
||||
runtime.database,
|
||||
instance_id,
|
||||
was_on_time=result.was_on_time,
|
||||
)
|
||||
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"override_id: presence override {override_id} does not exist"
|
||||
)
|
||||
await dao.delete(override_id)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
override.child_id,
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
async def _export_history_csv(_call: Any) -> None:
|
||||
raise HomeAssistantError(
|
||||
"CSV history export is not implemented until Feature 13"
|
||||
)
|
||||
|
||||
|
||||
def _manage_child(
|
||||
hass: HomeAssistant, find_runtime: FindRuntime
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
database = _require_runtime(hass, find_runtime).database
|
||||
data = call.data
|
||||
action = data["action"]
|
||||
if action == MANAGE_CHILD_CREATE:
|
||||
display_name = data.get("display_name")
|
||||
if display_name is None:
|
||||
raise HomeAssistantError(
|
||||
"display_name is required when action is create"
|
||||
)
|
||||
kwargs: dict[str, Any] = {}
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.create_child(
|
||||
database, display_name, **kwargs
|
||||
)
|
||||
return
|
||||
if action == MANAGE_CHILD_EDIT:
|
||||
child_id = data.get("child_id")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is edit"
|
||||
)
|
||||
kwargs = {}
|
||||
if "display_name" in data:
|
||||
kwargs["display_name"] = data["display_name"]
|
||||
if "colour" in data:
|
||||
kwargs["colour"] = data["colour"]
|
||||
if "avatar_ref" in data:
|
||||
kwargs["avatar_ref"] = data["avatar_ref"]
|
||||
if "sort_order" in data:
|
||||
kwargs["sort_order"] = data["sort_order"]
|
||||
await children_layer.edit_child(database, child_id, **kwargs)
|
||||
return
|
||||
if action == MANAGE_CHILD_SET_ACTIVE:
|
||||
child_id = data.get("child_id")
|
||||
is_active = data.get("is_active")
|
||||
if child_id is None:
|
||||
raise HomeAssistantError(
|
||||
"child_id is required when action is set_active"
|
||||
)
|
||||
if is_active is None:
|
||||
raise HomeAssistantError(
|
||||
"is_active is required when action is set_active"
|
||||
)
|
||||
await children_layer.set_child_active(
|
||||
database, child_id, is_active
|
||||
)
|
||||
return
|
||||
ordered_ids = data.get("ordered_ids")
|
||||
if ordered_ids is None:
|
||||
raise HomeAssistantError(
|
||||
"ordered_ids is required when action is reorder"
|
||||
)
|
||||
await children_layer.reorder_children(database, ordered_ids)
|
||||
|
||||
return _with_errors(_handler)
|
||||
|
||||
|
||||
def async_register_services(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
find_runtime: FindRuntime,
|
||||
regenerate_handler: ServiceHandler,
|
||||
) -> None:
|
||||
"""Register every canonical service once at domain scope.
|
||||
|
||||
Every service — regenerate included — passes through the Feature
|
||||
09 permission gate before its handler runs; the gate reads
|
||||
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
|
||||
registered exempt from the policy.
|
||||
"""
|
||||
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
|
||||
return
|
||||
gate = permission_gate
|
||||
specs: list[tuple[str, ServiceHandler, object]] = [
|
||||
(SERVICE_REGENERATE, regenerate_handler, None),
|
||||
(
|
||||
SERVICE_COMPLETE_QUEST,
|
||||
_complete_quest(hass, find_runtime),
|
||||
SCHEMA_COMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
_uncomplete_quest(hass, find_runtime),
|
||||
SCHEMA_UNCOMPLETE_QUEST,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_QUEST_DEFINITION,
|
||||
_create_quest_definition(hass, find_runtime),
|
||||
SCHEMA_CREATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
_update_quest_definition(hass, find_runtime),
|
||||
SCHEMA_UPDATE_QUEST_DEFINITION,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
|
||||
_set_quest_definition_active(hass, find_runtime),
|
||||
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
|
||||
),
|
||||
(
|
||||
SERVICE_SET_PRESENCE_PATTERN,
|
||||
_set_presence_pattern(hass, find_runtime),
|
||||
SCHEMA_SET_PRESENCE_PATTERN,
|
||||
),
|
||||
(
|
||||
SERVICE_CREATE_PRESENCE_OVERRIDE,
|
||||
_create_presence_override(hass, find_runtime),
|
||||
SCHEMA_CREATE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_DELETE_PRESENCE_OVERRIDE,
|
||||
_delete_presence_override(hass, find_runtime),
|
||||
SCHEMA_DELETE_PRESENCE_OVERRIDE,
|
||||
),
|
||||
(
|
||||
SERVICE_EXPORT_HISTORY_CSV,
|
||||
_export_history_csv,
|
||||
SCHEMA_EXPORT_HISTORY_CSV,
|
||||
),
|
||||
(
|
||||
SERVICE_MANAGE_CHILD,
|
||||
_manage_child(hass, find_runtime),
|
||||
SCHEMA_MANAGE_CHILD,
|
||||
),
|
||||
]
|
||||
|
||||
def _database_of(_call: Any) -> Any:
|
||||
return _require_runtime(hass, find_runtime).database
|
||||
|
||||
for service, handler, schema in specs:
|
||||
gated = gate(
|
||||
service, SERVICE_POLICY[service], _database_of
|
||||
)(handler)
|
||||
hass.services.async_register(DOMAIN, service, gated, schema=schema)
|
||||
|
||||
|
||||
def async_deregister_services(hass: HomeAssistant) -> None:
|
||||
"""Remove every canonical service when the last entry unloads."""
|
||||
for service in DOMAIN_SERVICES:
|
||||
if hass.services.has_service(DOMAIN, service):
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
@@ -0,0 +1,326 @@
|
||||
# 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.
|
||||
|
||||
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
|
||||
description: Mark a quest instance as completed.
|
||||
fields:
|
||||
instance_id:
|
||||
name: Instance ID
|
||||
description: The quest instance to complete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor:
|
||||
name: Actor
|
||||
description: Who completed the quest (panel or user).
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- panel
|
||||
- user
|
||||
actor_child_id:
|
||||
name: Actor child ID
|
||||
description: The tapped child profile. Required when actor is panel.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor_user_id:
|
||||
name: Actor user ID
|
||||
description: Home Assistant user id when actor is user and no call context is present.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
uncomplete_quest:
|
||||
name: Uncomplete quest
|
||||
description: Undo a quest instance completion.
|
||||
fields:
|
||||
instance_id:
|
||||
name: Instance ID
|
||||
description: The quest instance to uncomplete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor:
|
||||
name: Actor
|
||||
description: Who is undoing the completion (panel or user).
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- panel
|
||||
- user
|
||||
actor_child_id:
|
||||
name: Actor child ID
|
||||
description: The tapped child profile. Required when actor is panel.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
actor_user_id:
|
||||
name: Actor user ID
|
||||
description: Home Assistant user id when actor is user and no call context is present.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
create_quest_definition:
|
||||
name: Create quest definition
|
||||
description: Create a quest definition with its schedule rule, assignees and windows.
|
||||
fields:
|
||||
title:
|
||||
name: Title
|
||||
description: Display title for the quest.
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
rule:
|
||||
name: Rule
|
||||
description: Recurrence rule as a mapping (rule_type plus shape fields).
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
assignee_child_ids:
|
||||
name: Assignee child IDs
|
||||
description: Child ids this definition is assigned to.
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
windows:
|
||||
name: Windows
|
||||
description: Day windows (morning, afternoon, evening), optionally with a due time.
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
description:
|
||||
name: Description
|
||||
description: Optional longer description.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
name: Icon
|
||||
description: Optional icon name.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
update_quest_definition:
|
||||
name: Update quest definition
|
||||
description: Edit a quest definition's title, description, icon, rule or windows.
|
||||
fields:
|
||||
definition_id:
|
||||
name: Definition ID
|
||||
description: The quest definition to update.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
title:
|
||||
name: Title
|
||||
description: Replacement display title.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
description:
|
||||
name: Description
|
||||
description: Replacement description, or null to clear.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
name: Icon
|
||||
description: Replacement icon, or null to clear.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
rule:
|
||||
name: Rule
|
||||
description: Replacement recurrence rule as a mapping.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
windows:
|
||||
name: Windows
|
||||
description: Replacement day windows.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
|
||||
set_quest_definition_active:
|
||||
name: Set quest definition active
|
||||
description: Deactivate or reactivate a quest definition.
|
||||
fields:
|
||||
definition_id:
|
||||
name: Definition ID
|
||||
description: The quest definition to update.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
is_active:
|
||||
name: Active
|
||||
description: Whether the definition should generate future instances.
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
set_presence_pattern:
|
||||
name: Set presence pattern
|
||||
description: Upsert a child's repeating presence schedule.
|
||||
fields:
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: The child whose schedule is being set.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
cycle_length_weeks:
|
||||
name: Cycle length weeks
|
||||
description: Number of weeks in the repeating cycle (1 to 4).
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
min: 1
|
||||
max: 4
|
||||
anchor_date:
|
||||
name: Anchor date
|
||||
description: Cycle anchor as a YYYY-MM-DD date.
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
pattern:
|
||||
name: Pattern
|
||||
description: Mapping of week index to present weekday ints (Monday is 0).
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
create_presence_override:
|
||||
name: Create presence override
|
||||
description: Create a date-range presence override for a child.
|
||||
fields:
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: The child the override applies to.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
start_date:
|
||||
name: Start date
|
||||
description: Inclusive start date (YYYY-MM-DD).
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
end_date:
|
||||
name: End date
|
||||
description: Inclusive end date (YYYY-MM-DD).
|
||||
required: true
|
||||
selector:
|
||||
date:
|
||||
is_present:
|
||||
name: Present
|
||||
description: Whether the child is present for this range.
|
||||
required: true
|
||||
selector:
|
||||
boolean:
|
||||
note:
|
||||
name: Note
|
||||
description: Optional note stored with the override.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
delete_presence_override:
|
||||
name: Delete presence override
|
||||
description: Delete a presence override by id.
|
||||
fields:
|
||||
override_id:
|
||||
name: Override ID
|
||||
description: The override to delete.
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
|
||||
export_history_csv:
|
||||
name: Export history CSV
|
||||
description: Export completion history as CSV. Not implemented until Feature 13.
|
||||
|
||||
manage_child:
|
||||
name: Manage child
|
||||
description: Create, edit, activate or reorder child profiles.
|
||||
fields:
|
||||
action:
|
||||
name: Action
|
||||
description: create, edit, set_active, or reorder.
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- create
|
||||
- edit
|
||||
- set_active
|
||||
- reorder
|
||||
child_id:
|
||||
name: Child ID
|
||||
description: Required for edit and set_active.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
display_name:
|
||||
name: Display name
|
||||
description: Required for create; optional for edit.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
colour:
|
||||
name: Colour
|
||||
description: Optional colour string.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
avatar_ref:
|
||||
name: Avatar
|
||||
description: Optional avatar reference.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
sort_order:
|
||||
name: Sort order
|
||||
description: Optional panel sort order.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
is_active:
|
||||
name: Active
|
||||
description: Required for set_active.
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
ordered_ids:
|
||||
name: Ordered IDs
|
||||
description: Complete permutation of child ids. Required for reorder.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
@@ -1,20 +1,32 @@
|
||||
"""SQLite storage path resolution for the NestQuest integration."""
|
||||
"""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 .const import SQLITE_DB_FILENAME
|
||||
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
|
||||
|
||||
|
||||
def _ensure_dir(path: Path) -> None:
|
||||
"""Create the given directory and any missing parents."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
__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."""
|
||||
db_path = Path(hass.config.path(SQLITE_DB_FILENAME)).absolute()
|
||||
await hass.async_add_executor_job(_ensure_dir, db_path.parent)
|
||||
return db_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))
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
/* ============================================================
|
||||
NestQuest — Parent admin card tokens
|
||||
Surface: phone, 390x844 reference. Plain CubeCraft styling,
|
||||
no tabletop treatment, plain domain words in the UI.
|
||||
Scope on the card host: nestquest-admin-card.
|
||||
Derived from the approved mockups (admin screens 1d-1i).
|
||||
Two densities were built; COMPACT is the default. Flip
|
||||
--nq-a-density-* to the roomy values for the alternative.
|
||||
============================================================ */
|
||||
|
||||
:host {
|
||||
/* ── Brand + neutrals (CubeCraft tokens, verbatim) ─────── */
|
||||
--nq-brand-blue: #3B3AB8;
|
||||
--nq-brand-purple: #A035CC;
|
||||
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
|
||||
--nq-a-page: #F4F4F6;
|
||||
--nq-a-surface: #FFFFFF;
|
||||
--nq-a-surface-subtle: #FAFAFA;
|
||||
--nq-a-selected: #EBEBF8; /* row being edited */
|
||||
--nq-a-border: #E4E4EA;
|
||||
--nq-a-divider: #EDEDF1;
|
||||
|
||||
--nq-a-ink: #18181D;
|
||||
--nq-a-ink-secondary: #52525E;
|
||||
--nq-a-ink-tertiary: #70707E; /* inactive rows, unselected nav icons */
|
||||
|
||||
--nq-a-success: #15803D;
|
||||
--nq-a-danger: #DC2626;
|
||||
--nq-a-danger-strong: #B91C1C;
|
||||
--nq-a-danger-bg: #FEE2E2;
|
||||
--nq-a-warning-ink: #92400E;
|
||||
--nq-a-warning-ink-2: #78350F;
|
||||
--nq-a-warning-bg: #FEF3C7;
|
||||
--nq-a-warning-border: rgba(217,119,6,.35);
|
||||
--nq-a-info-bg: #DBEAFE;
|
||||
--nq-a-info-ink: #1E3A8A;
|
||||
--nq-a-info-icon: #1D4ED8;
|
||||
--nq-a-reversal: #7D28A0; /* un-complete events in history */
|
||||
|
||||
/* ── Type ─────────────────────────────────────────────── */
|
||||
--nq-a-font: Nunito, system-ui, sans-serif;
|
||||
--nq-a-size-screen: 24px; /* 800, screen title */
|
||||
--nq-a-size-hero: 32px; /* 800, roomy "8 of 12 done" */
|
||||
--nq-a-size-card-title: 19px; /* 800, roomy child card */
|
||||
--nq-a-size-stat: 22px; /* 800, compact stat number */
|
||||
--nq-a-size-row: 13.5px;/* 700, list row title */
|
||||
--nq-a-size-meta: 11px; /* 600, row meta */
|
||||
--nq-a-size-label: 10px; /* 700, uppercase field label */
|
||||
--nq-a-track-label: .10em;
|
||||
|
||||
/* ── Density: COMPACT (default) ───────────────────────── */
|
||||
--nq-a-density-page-pad: 14px 16px 92px; /* bottom clears the 70px tab bar */
|
||||
--nq-a-density-gap: 10px;
|
||||
--nq-a-density-card-pad: 13px;
|
||||
--nq-a-density-row-pad: 11px 13px;
|
||||
--nq-a-density-radius: 8px;
|
||||
--nq-a-density-shadow: none;
|
||||
/* Density: ROOMY — page-pad 18px 20px 92px; gap 16px;
|
||||
card-pad 18px; radius 12px; shadow 0 4px 12px rgba(0,0,0,.06) */
|
||||
|
||||
/* ── Metrics ──────────────────────────────────────────── */
|
||||
--nq-a-tabbar-height: 70px; /* + 12px safe-area padding */
|
||||
--nq-a-tap-min: 44px; /* every row/control clears this */
|
||||
--nq-a-radius-pill: 9999px;
|
||||
--nq-a-radius-sheet: 20px 20px 0 0;
|
||||
--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);
|
||||
--nq-a-progress-height: 6px; /* 8px roomy, 10px hero */
|
||||
|
||||
/* ── Motion ───────────────────────────────────────────── */
|
||||
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
|
||||
--nq-dur-micro: 120ms;
|
||||
--nq-dur-base: 200ms;
|
||||
--nq-dur-modal: 350ms;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-400-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-600-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("./fonts/nunito-latin-800-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-600-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-latin-900-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel Decorative";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-decorative-latin-700-normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Cinzel Decorative";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("./fonts/cinzel-decorative-latin-900-normal.woff2") format("woff2");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/* ============================================================
|
||||
NestQuest — Kids' panel card tokens
|
||||
Surface: DAKboard DAKOS wall panel, 1920x1080 landscape,
|
||||
viewed from ~3 ft, no login, read/complete only.
|
||||
Scope these on the card host: nestquest-quest-log-card,
|
||||
nestquest-party-board-card.
|
||||
Derived from the approved mockups (panel screens 1a, 1b, 1c, 1j).
|
||||
============================================================ */
|
||||
|
||||
:host {
|
||||
/* ── Parchment ground ─────────────────────────────────── */
|
||||
--nq-p-parchment-top: #f0e4c7;
|
||||
--nq-p-parchment-bottom: #ddcca3;
|
||||
--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%),
|
||||
radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%),
|
||||
repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px),
|
||||
repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px),
|
||||
linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));
|
||||
--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);
|
||||
|
||||
/* Card surfaces sitting on parchment */
|
||||
--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9); /* incomplete quest */
|
||||
--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2); /* sealed quest */
|
||||
--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca); /* adventurer plate, stat block */
|
||||
--nq-p-card-border: rgba(120,88,48,.38);
|
||||
--nq-p-panel-border: rgba(92,62,26,.40);
|
||||
--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.20), inset 0 1px 0 rgba(255,255,255,.70);
|
||||
--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.70);
|
||||
--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);
|
||||
--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);
|
||||
--nq-p-rule: 2px solid rgba(92,62,26,.35);
|
||||
|
||||
/* ── Ink (all values >= 4.5:1 on parchment) ───────────── */
|
||||
--nq-p-ink: #2b1f14; /* titles, quest names */
|
||||
--nq-p-ink-secondary: #5c452a; /* meta, window labels, counts */
|
||||
--nq-p-ink-muted: #6f6455; /* sealed quest titles only */
|
||||
--nq-p-ink-away: #4d4433; /* away adventurer name */
|
||||
--nq-p-ink-late: #8f1526; /* overdue line */
|
||||
|
||||
/* ── Brand (CubeCraft, toned down: accents only) ──────── */
|
||||
--nq-brand-blue: #3B3AB8;
|
||||
--nq-brand-purple: #A035CC;
|
||||
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
--nq-p-icon-tile: var(--nq-brand-gradient); /* open quest icon tile */
|
||||
--nq-p-icon-tile-done: rgba(92,62,26,.16);
|
||||
--nq-p-crest: var(--nq-brand-gradient);
|
||||
--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);
|
||||
|
||||
/* ── Wax seal (completed marker) ──────────────────────── */
|
||||
--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);
|
||||
--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.40), inset 0 0 0 4px rgba(255,255,255,.14);
|
||||
--nq-p-seal-size: 78px; /* 168px on the quest-complete screen */
|
||||
|
||||
/* ── Dock (bottom strip, non-interactive) ─────────────── */
|
||||
--nq-p-dock-bg: rgba(43,31,20,.90);
|
||||
--nq-p-dock-ink: #f7efdb;
|
||||
--nq-p-dock-ink-secondary: #d8c9a6;
|
||||
--nq-p-dock-divider: rgba(233,220,189,.30);
|
||||
--nq-p-dock-height: 84px;
|
||||
|
||||
/* ── Type ─────────────────────────────────────────────── */
|
||||
--nq-p-font-display: 'Cinzel Decorative', Cinzel, serif; /* NestQuest, names, Quest complete */
|
||||
--nq-p-font-heading: Cinzel, serif; /* section heads, quest titles, numerals */
|
||||
--nq-p-font-body: Nunito, system-ui, sans-serif; /* meta lines, dock */
|
||||
|
||||
--nq-p-size-hero: 86px; /* 900 weight, "Quest complete" */
|
||||
--nq-p-size-wordmark: 66px; /* 900 weight */
|
||||
--nq-p-size-title: 56px; /* 700, "Declan's Quest Log" */
|
||||
--nq-p-size-name: 48px; /* 700, adventurer name */
|
||||
--nq-p-size-section: 32px; /* 700, Morning / Afternoon / Evening */
|
||||
--nq-p-size-quest: 30px; /* 700, quest title */
|
||||
--nq-p-size-body: 22px; /* 600 Nunito, meta — PANEL MINIMUM */
|
||||
--nq-p-size-label: 21px; /* 600 Cinzel, uppercase, tracked */
|
||||
--nq-p-track-label: .14em;
|
||||
--nq-p-track-kicker: .30em;
|
||||
|
||||
/* ── Metrics: touch ───────────────────────────────────── */
|
||||
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
|
||||
--nq-p-quest-gap: 24px; /* separation between tap targets */
|
||||
--nq-p-quest-pad: 22px; /* padding inside a quest card */
|
||||
--nq-p-tile-size: 64px; /* quest icon tile */
|
||||
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
|
||||
--nq-p-button-min-width: 150px;
|
||||
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
|
||||
--nq-p-radius-card: 12px;
|
||||
--nq-p-radius-panel: 20px;
|
||||
--nq-p-radius-pill: 9999px;
|
||||
--nq-p-page-inset: 62px;
|
||||
--nq-p-frame-inset: 26px;
|
||||
|
||||
/* ── Motion (CubeCraft easing) ────────────────────────── */
|
||||
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
|
||||
--nq-dur-micro: 120ms;
|
||||
--nq-dur-base: 200ms;
|
||||
--nq-dur-modal: 350ms;
|
||||
}
|
||||
@@ -90,6 +90,7 @@ child_order: [declan, jordyn, chloe]
|
||||
# kids' panel, view 2
|
||||
type: custom:nestquest-quest-log-card
|
||||
board_path: /nestquest/board
|
||||
weather_entity: weather.home # optional; dock falls back to date/time
|
||||
idle_return_seconds: 40 # must be shorter than TouchHub Auto-Return
|
||||
confirm_timeout_seconds: 15
|
||||
complete_screen_seconds: 12
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Panel empty & error states — design addendum
|
||||
|
||||
Closes the gap DESIGN-HANDOFF.md lists under "Open questions": *"Empty and error
|
||||
states are unspecified by request (child with no quests, everyone away, integration
|
||||
unconfigured, DB unreachable). Panel must not render a blank parchment — decide
|
||||
before shipping the card."*
|
||||
|
||||
All decisions below reuse PANEL-SPEC.md's approved aesthetic — parchment ground,
|
||||
double frame, dock, panel tokens, no text below 22px, no alpha-muted ink — and add
|
||||
one shared component per card: a **notice panel** (radius 20px, `--nq-p-card-panel`,
|
||||
2px `--nq-p-panel-border`, `--nq-p-panel-shadow`, centred icon + headline + body).
|
||||
The frame, wordmark row, kicker and dock always render; only the content area
|
||||
changes. **No state renders a blank parchment.**
|
||||
|
||||
## 1. What the data can say
|
||||
|
||||
The cards read `sensor.nestquest_<child>_quests_due_today` (plus siblings). One of
|
||||
four things comes back, and the card must tell them apart:
|
||||
|
||||
| Backend condition | What the card sees | Card's interpretation |
|
||||
|---|---|---|
|
||||
| Resolving (DB fine) | numeric state + attributes (`child_name`, `present`, `instances`) | normal, all states below |
|
||||
| Integration not configured | entity does not exist at all | **not set up** |
|
||||
| Backend unreachable (DB locked, refresh failed) | state `unavailable` | **records unreachable** |
|
||||
| Child deactivated / absent from snapshot | state `unknown` | **records unreachable** (same panel voice: the records do not answer) |
|
||||
|
||||
An away child (presence `present: false`, from the due-sensor attributes with the
|
||||
presence binary sensor as fallback) is *not* an error: it is a first-class panel
|
||||
state ("On travels"). Away children get no instances (materialiser skips them), so
|
||||
away is detected from the presence flag, never from a zero quest count.
|
||||
|
||||
## 2. Quest log — state priority
|
||||
|
||||
`nestquest-quest-log-card` resolves exactly one state, in this order:
|
||||
|
||||
1. **No adventurer in the path** (direct navigation to a URL with no child slug —
|
||||
misconfigured `board_path`/`quest_log_path`) → notice *"No adventurer chosen"*,
|
||||
body *"Open The Party and tap your crest to open your quest log."*
|
||||
2. **Not set up** (entity missing) → notice *"NestQuest is not set up yet"*, body
|
||||
*"A parent needs to finish setting up NestQuest."*
|
||||
3. **Records unreachable** (`unavailable`/`unknown`) → notice *"The records cannot
|
||||
be reached"*, body *"The party's records are quiet right now. NestQuest will
|
||||
return shortly."*
|
||||
4. **Away** (present flag false) → away notice: dashed muted panel (the away-plate
|
||||
gradient), tent icon, *"<Name> is on travels"*, *"The quest log unlocks when
|
||||
they return."* and, when `next_present` is known, *"Returns <weekday>, <Mon D>"*.
|
||||
Direct navigation is the one path that can reach this state (the board's away
|
||||
plate is not tappable); the log answers it honestly instead of showing an empty
|
||||
column set. Header renders with the crest in away colours and the sub line
|
||||
`… · On travels`; the remaining-count block is hidden (it is meaningless while
|
||||
away).
|
||||
5. **No quests today** (present, due = 0) → all-clear notice, sun icon, *"No quests
|
||||
today"*, body *"Nothing was posted for today, <Name>. Enjoy the day's rest!"* —
|
||||
never three bare empty columns. The header and remaining block stay (0 quests
|
||||
left, party progress), so the log still reads like the log.
|
||||
6. **All quests done** (due > 0, remaining = 0) → the fallback celebration that
|
||||
shows until the 1c quest-complete screen takes over: centred wax-seal mark (the
|
||||
`--nq-p-seal` tokens at 78px, check glyph, −6°) with headline *"Quest complete"*
|
||||
— the same phrase screen 1c uses — and body *"Every quest is claimed, <Name>.
|
||||
The seal is set for today. Returning to The Party shortly."* The idle return
|
||||
(§3.5) is what actually navigates back; 1c remains the designed takeover.
|
||||
7. **Normal** → the three-window column layout of PANEL-SPEC §3.1 unchanged. A
|
||||
window with no quests still renders its head alone, exactly as the spec says.
|
||||
|
||||
Notices sit in the columns' content area, vertically centred. Error notices (states
|
||||
1–3) replace the header entirely — there is no honest name to headline when the
|
||||
records do not answer — while states 4–6 keep the full header.
|
||||
|
||||
## 3. Party board — state priority
|
||||
|
||||
`nestquest-party-board-card` resolves per configured child, then at board level:
|
||||
|
||||
1. **No children configured** (`child_order` empty) → board notice *"NestQuest is
|
||||
not set up yet"*, body *"A parent needs to finish setting up NestQuest before
|
||||
the party can gather."*
|
||||
2. **Every configured child unresolvable** → one board notice instead of a grid of
|
||||
broken plates: *"NestQuest is not set up yet"* when the entities are missing,
|
||||
*"The records cannot be reached"* when they answer `unavailable`/`unknown`.
|
||||
3. **Partial** (some children resolve, some do not) → the board still renders the
|
||||
resolving plates; an unresolvable child renders a muted, dashed plate in the
|
||||
away geometry with a compass icon and the pill label **"Unknown"**, progress
|
||||
line `—`, empty bar, **not tappable**. It must not claim "Home today" (0 of 0)
|
||||
and must not lie "On travels": the records simply do not say.
|
||||
4. **Normal** → the plates grid of PANEL-SPEC §2 unchanged (present + away plates).
|
||||
|
||||
The hint line ("Tap your crest…") is hidden in board-level notice states — there
|
||||
is nothing to tap.
|
||||
|
||||
## 4. Shared rules
|
||||
|
||||
- **Never a blank parchment.** Every render path emits the frames, wordmark,
|
||||
kicker, dock, and either content or a notice panel. The dock already degrades to
|
||||
date + time when weather is unset (PANEL-SPEC §2.3); that behaviour is unchanged.
|
||||
- **Notice panel type scale:** icon 46px, headline 40px/700 Cinzel
|
||||
(`--nq-p-font-heading`, `--nq-p-ink`), body 24px/600 Nunito
|
||||
(`--nq-p-ink-secondary`), `role="status"` so assistive tech announces it.
|
||||
- **Tone.** The panel speaks tabletop (PANEL-SPEC vocabulary) but never lies: an
|
||||
error says the records cannot be reached, not that everything is fine. Error
|
||||
copy names the actor who can fix it (a parent) or promises the return of the
|
||||
data — no stack traces, no entity IDs.
|
||||
- **No interaction in error/away states.** No confirm dialog, no idle return
|
||||
changes, no navigation attempts. Notices are static.
|
||||
- Both cards implement the same resolution rules, so the board and the log never
|
||||
disagree about whether the integration is up.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 679 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -77,6 +77,8 @@
|
||||
/* ── Metrics: touch ───────────────────────────────────── */
|
||||
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
|
||||
--nq-p-quest-gap: 24px; /* separation between tap targets */
|
||||
--nq-p-quest-pad: 22px; /* padding inside a quest card */
|
||||
--nq-p-tile-size: 64px; /* quest icon tile */
|
||||
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
|
||||
--nq-p-button-min-width: 150px;
|
||||
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
|
||||
|
||||
Generated
+1232
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "nestquest-cards",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import adminTokens from "../../../custom_components/nestquest/www/nestquest-admin-tokens.css?inline";
|
||||
|
||||
import { registerCustomCard, type CardConfig, type HassLike } from "../types";
|
||||
|
||||
export class NestQuestAdminCard extends LitElement {
|
||||
static properties = {
|
||||
hass: { attribute: false },
|
||||
_config: { state: true },
|
||||
};
|
||||
|
||||
static styles = [unsafeCSS(adminTokens)];
|
||||
|
||||
hass?: HassLike;
|
||||
_config?: CardConfig;
|
||||
|
||||
setConfig(config: CardConfig): void {
|
||||
if (!config || typeof config !== "object") {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
getCardSize(): number {
|
||||
return 8;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div>NestQuest Admin</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("nestquest-admin-card", NestQuestAdminCard);
|
||||
registerCustomCard({
|
||||
type: "nestquest-admin-card",
|
||||
name: "NestQuest Admin",
|
||||
description: "Parent phone view for today, tasks, schedule, and history.",
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import "./cards/party-board";
|
||||
import "./cards/quest-log";
|
||||
import "./cards/admin";
|
||||
|
||||
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
|
||||
if (!document.querySelector('link[data-nq-fonts=""]')) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = FONTS_HREF;
|
||||
link.dataset.nqFonts = "";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type HassLike = Record<string, unknown>;
|
||||
|
||||
export type CardConfig = Record<string, unknown>;
|
||||
|
||||
export type CustomCardInfo = {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
customCards?: CustomCardInfo[];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCustomCard(info: CustomCardInfo): void {
|
||||
window.customCards = window.customCards ?? [];
|
||||
window.customCards.push(info);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: "../custom_components/nestquest/www",
|
||||
emptyOutDir: false,
|
||||
sourcemap: false,
|
||||
lib: {
|
||||
entry: "src/nestquest-cards.ts",
|
||||
formats: ["es"],
|
||||
fileName: () => "nestquest-cards.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+12
-1
@@ -1,15 +1,26 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.3.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",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pyyaml>=6",
|
||||
"uvicorn>=0.30",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Shared harness for the NestQuest automation-blueprint tests.
|
||||
|
||||
The blueprints are YAML data consumed by Home Assistant's blueprint
|
||||
engine; the integration itself never imports yaml or jinja2. The
|
||||
blueprint tests therefore exercise them exactly the way HA would:
|
||||
parse the YAML (with a loader that understands ``!input`` tags), then
|
||||
render the message/condition templates with a plain jinja2 environment
|
||||
over a fixture state set shaped like HA's template state objects.
|
||||
|
||||
The harness is mock-only (see conftest.py), so the fixtures model just
|
||||
the surface the templates touch: ``states.binary_sensor`` /
|
||||
``states.sensor`` lists of objects carrying ``entity_id``, ``state``
|
||||
and ``attributes`` — plus, for the celebration blueprint, a callable
|
||||
``states(entity_id)`` lookup over ``input_datetime`` helpers. Jinja's
|
||||
``search``/``match`` selectattr tests are Home Assistant additions,
|
||||
registered here explicitly.
|
||||
|
||||
This module is plain helper code (deliberately not named ``test_*`` so
|
||||
pytest never collects it); every blueprint test file imports from it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
BLUEPRINT_DIR = (
|
||||
REPO_ROOT / "custom_components" / "nestquest" / "blueprints" / "automation"
|
||||
)
|
||||
|
||||
|
||||
class BlueprintInput(str):
|
||||
"""Marker for a parsed blueprint ``!input <name>`` reference."""
|
||||
|
||||
|
||||
def _input_constructor(loader: yaml.SafeLoader, node: yaml.Node) -> BlueprintInput:
|
||||
return BlueprintInput(loader.construct_scalar(node))
|
||||
|
||||
|
||||
class _BlueprintLoader(yaml.SafeLoader):
|
||||
"""SafeLoader extended with Home Assistant's ``!input`` blueprint tag."""
|
||||
|
||||
|
||||
_BlueprintLoader.add_constructor("!input", _input_constructor)
|
||||
|
||||
|
||||
def load_blueprints() -> dict[str, dict]:
|
||||
"""Parse every automation blueprint, asserting the directory exists."""
|
||||
assert BLUEPRINT_DIR.is_dir(), f"missing blueprint directory {BLUEPRINT_DIR}"
|
||||
paths = sorted(BLUEPRINT_DIR.glob("*.yaml"))
|
||||
assert paths, f"no blueprints under {BLUEPRINT_DIR}"
|
||||
return {
|
||||
path.stem: yaml.load(path.read_text(encoding="utf-8"), Loader=_BlueprintLoader)
|
||||
for path in paths
|
||||
}
|
||||
|
||||
|
||||
def blueprint(name: str) -> dict:
|
||||
"""Return one parsed blueprint by its file stem."""
|
||||
blueprints = load_blueprints()
|
||||
assert name in blueprints, f"{name}.yaml is missing"
|
||||
return blueprints[name]
|
||||
|
||||
|
||||
def morning_summary() -> dict:
|
||||
"""Return the parsed morning-summary blueprint."""
|
||||
return blueprint("morning_summary")
|
||||
|
||||
|
||||
def single_action(bp: dict) -> dict:
|
||||
"""Return the single action of a one-action blueprint."""
|
||||
actions = bp["actions"]
|
||||
assert isinstance(actions, list) and len(actions) == 1
|
||||
return actions[0]
|
||||
|
||||
|
||||
def morning_action(bp: dict) -> dict:
|
||||
"""Return the single notify action of the morning summary."""
|
||||
return single_action(bp)
|
||||
|
||||
|
||||
def notify_data(bp: dict, index: int = 0) -> dict:
|
||||
"""Return a notify action's service ``data`` (title/message live
|
||||
there in the modern HA action schema, not at the action's top level)."""
|
||||
action = bp["actions"][index]
|
||||
assert isinstance(action.get("data"), dict), (
|
||||
"notify payload fields must nest under data:"
|
||||
)
|
||||
return action["data"]
|
||||
|
||||
|
||||
def iter_strings(node: object):
|
||||
"""Yield every string value in a parsed YAML structure, recursively."""
|
||||
if isinstance(node, str):
|
||||
yield node
|
||||
elif isinstance(node, dict):
|
||||
for value in node.values():
|
||||
yield from iter_strings(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
yield from iter_strings(value)
|
||||
|
||||
|
||||
class TemplateState:
|
||||
"""Stand-in for an HA template state object: entity_id/state/attributes."""
|
||||
|
||||
def __init__(self, entity_id: str, state: str, attributes: dict | None = None):
|
||||
self.entity_id = entity_id
|
||||
self.state = state
|
||||
self.attributes = dict(attributes or {})
|
||||
|
||||
|
||||
class StatesFixture:
|
||||
"""HA's template ``states`` object over a fixture entity set.
|
||||
|
||||
Like HA's ``AllStates``, it exposes per-domain lists (``.sensor``,
|
||||
``.binary_sensor``, ``.input_datetime``, ``.input_text``) AND is
|
||||
callable as ``states(entity_id)`` returning the entity's state
|
||||
string — or ``None`` when the fixture holds no such entity, the
|
||||
way HA returns ``None`` for a nonexistent entity id.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, binary_sensors=(), sensors=(), input_datetimes=(), input_texts=()
|
||||
):
|
||||
self.binary_sensor = list(binary_sensors)
|
||||
self.sensor = list(sensors)
|
||||
self.input_datetime = list(input_datetimes)
|
||||
self.input_text = list(input_texts)
|
||||
self._all = (
|
||||
self.binary_sensor
|
||||
+ self.sensor
|
||||
+ self.input_datetime
|
||||
+ self.input_text
|
||||
)
|
||||
|
||||
def __call__(self, entity_id: str):
|
||||
"""Return the entity's state string, or None when absent."""
|
||||
for entity in self._all:
|
||||
if entity.entity_id == entity_id:
|
||||
return entity.state
|
||||
return None
|
||||
|
||||
|
||||
def make_states(
|
||||
binary_sensors=(), sensors=(), input_datetimes=(), input_texts=()
|
||||
) -> StatesFixture:
|
||||
"""Build the ``states`` fixture: per-domain lists of TemplateState."""
|
||||
return StatesFixture(
|
||||
binary_sensors, sensors, input_datetimes, input_texts
|
||||
)
|
||||
|
||||
|
||||
def make_template_environment() -> jinja2.Environment:
|
||||
"""A plain jinja2 environment with HA's regex tests and bool filter."""
|
||||
env = jinja2.Environment()
|
||||
env.tests["match"] = lambda value, pattern: re.match(pattern, value) is not None
|
||||
env.tests["search"] = lambda value, pattern: re.search(pattern, value) is not None
|
||||
|
||||
def _bool(value, default=False):
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("true", "on", "open", "yes", "1")
|
||||
return bool(value) or default
|
||||
|
||||
env.filters["bool"] = _bool
|
||||
return env
|
||||
|
||||
|
||||
def render(template: str, **variables) -> str:
|
||||
"""Render a template the way HA would substitute its variables."""
|
||||
return make_template_environment().from_string(template).render(**variables)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entity-reference discipline (Feature 10 shapes only). The scanners
|
||||
# are shared so every blueprint test file can assert the same rules;
|
||||
# the directory-wide tests in test_blueprints.py apply them to every
|
||||
# blueprint file automatically.
|
||||
# ---------------------------------------------------------------------------
|
||||
#: Entity shapes Feature 10 actually creates (sensor.py, binary_sensor.py).
|
||||
ALLOWED_ENTITY_SHAPES: tuple[re.Pattern, ...] = tuple(
|
||||
re.compile(pattern)
|
||||
for pattern in (
|
||||
r"sensor\.nestquest_[a-z0-9_]+_quests_due_today",
|
||||
r"sensor\.nestquest_[a-z0-9_]+_quests_remaining_today",
|
||||
r"sensor\.nestquest_[a-z0-9_]+_quests_completed_today",
|
||||
r"sensor\.nestquest_[a-z0-9_]+_completion_pct_today",
|
||||
r"sensor\.nestquest_[a-z0-9_]+_next_quest",
|
||||
r"binary_sensor\.nestquest_[a-z0-9_]+_all_done",
|
||||
r"binary_sensor\.nestquest_[a-z0-9_]+_present_today",
|
||||
r"sensor\.nestquest_household_quests_due_today",
|
||||
r"sensor\.nestquest_household_quests_completed_today",
|
||||
r"sensor\.nestquest_cycle_day",
|
||||
)
|
||||
)
|
||||
|
||||
#: An unescaped, id-like entity reference: any found must be a full
|
||||
#: match for one of the Feature 10 shapes above.
|
||||
_ENTITY_LITERAL_RE = re.compile(r"(?<![\\\w])(?:sensor|binary_sensor)\.[A-Za-z0-9_]+")
|
||||
|
||||
#: A domain-dot mention, ignoring the template's escaped-dot regexes
|
||||
#: (one backslash is stripped so ``binary_sensor\\.`` counts too).
|
||||
_MENTION_RE = re.compile(r"(?:sensor|binary_sensor)\.([A-Za-z0-9_]+)")
|
||||
+566
-2
@@ -22,11 +22,13 @@ 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 sys
|
||||
import tempfile
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -44,6 +46,14 @@ _HA_MODULES = (
|
||||
"homeassistant.exceptions",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.config_validation",
|
||||
"homeassistant.helpers.entity",
|
||||
"homeassistant.helpers.event",
|
||||
"homeassistant.helpers.update_coordinator",
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.sensor",
|
||||
"homeassistant.components.binary_sensor",
|
||||
"homeassistant.components.http",
|
||||
"homeassistant.components.frontend",
|
||||
)
|
||||
|
||||
def _find_spec(name: str):
|
||||
@@ -83,6 +93,14 @@ for _parent, _child in (
|
||||
("homeassistant", "exceptions"),
|
||||
("homeassistant", "helpers"),
|
||||
("homeassistant.helpers", "config_validation"),
|
||||
("homeassistant.helpers", "entity"),
|
||||
("homeassistant.helpers", "event"),
|
||||
("homeassistant.helpers", "update_coordinator"),
|
||||
("homeassistant", "components"),
|
||||
("homeassistant.components", "sensor"),
|
||||
("homeassistant.components", "binary_sensor"),
|
||||
("homeassistant.components", "http"),
|
||||
("homeassistant.components", "frontend"),
|
||||
):
|
||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||
|
||||
@@ -175,7 +193,11 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
|
||||
_data_entry_flow_mock.FlowResult = dict
|
||||
|
||||
|
||||
class ConfigEntryNotReady(Exception):
|
||||
class HomeAssistantError(Exception):
|
||||
"""Stand-in mirroring homeassistant.exceptions.HomeAssistantError."""
|
||||
|
||||
|
||||
class ConfigEntryNotReady(HomeAssistantError):
|
||||
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
|
||||
|
||||
HA retries setup when this is raised; the tests assert the corrupt
|
||||
@@ -183,7 +205,256 @@ class ConfigEntryNotReady(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class Unauthorized(HomeAssistantError):
|
||||
"""Stand-in mirroring homeassistant.exceptions.Unauthorized."""
|
||||
|
||||
|
||||
_exceptions_mock.HomeAssistantError = HomeAssistantError
|
||||
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
|
||||
_exceptions_mock.Unauthorized = Unauthorized
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceInfo:
|
||||
"""Stand-in for homeassistant.helpers.entity.DeviceInfo.
|
||||
|
||||
The kwargs-only shape the integration builds per-child devices
|
||||
with: ``identifiers``, ``name``, ``manufacturer``, ``model``.
|
||||
"""
|
||||
|
||||
identifiers: object = None
|
||||
name: str | None = None
|
||||
manufacturer: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class Entity:
|
||||
"""Stand-in for homeassistant.helpers.entity.Entity.
|
||||
|
||||
Mirrors the ``_attr_*`` attribute contract through the ``name``,
|
||||
``unique_id``, ``device_info`` and ``extra_state_attributes``
|
||||
properties, and ``async_write_ha_state`` registering the entity
|
||||
into the hass entity registry (``hass.entities``, a dict keyed by
|
||||
``unique_id`` storing the live object) — the harness's stand-in
|
||||
for HA's state machine, so tests enumerate entities and read
|
||||
their state directly off the registered object.
|
||||
"""
|
||||
|
||||
hass = None
|
||||
_attr_name: str | None = None
|
||||
_attr_unique_id: str | None = None
|
||||
_attr_device_info: DeviceInfo | None = None
|
||||
_attr_extra_state_attributes: dict | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
return self._attr_name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
return self._attr_unique_id
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo | None:
|
||||
return self._attr_device_info
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict | None:
|
||||
return self._attr_extra_state_attributes
|
||||
|
||||
def async_write_ha_state(self) -> None:
|
||||
"""Register the entity under its unique_id (the mock state write)."""
|
||||
if self.hass is not None and self.unique_id is not None:
|
||||
self.hass.entities[self.unique_id] = self
|
||||
|
||||
|
||||
class CoordinatorEntity(Entity):
|
||||
"""Stand-in for homeassistant.helpers.update_coordinator.CoordinatorEntity.
|
||||
|
||||
Real HA subscribes the entity to coordinator notifications in the
|
||||
platform-scheduler-driven ``async_added_to_hass`` lifecycle hook;
|
||||
the stand-in subscribes eagerly in ``__init__`` so the synchronous
|
||||
``AddEntitiesCallback`` surface stays synchronous (the mock
|
||||
harness has no lifecycle scheduler). ``_handle_coordinator_update``
|
||||
mirrors HA: one state write per coordinator notification.
|
||||
"""
|
||||
|
||||
def __init__(self, coordinator) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.coordinator.async_add_listener(self._handle_coordinator_update)
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class SensorStateClass:
|
||||
"""Stand-in namespace for homeassistant.components.sensor.SensorStateClass.
|
||||
|
||||
Only the members the integration uses (plus TOTAL for the
|
||||
long-history sensors later tasks add).
|
||||
"""
|
||||
|
||||
MEASUREMENT = "measurement"
|
||||
TOTAL = "total"
|
||||
|
||||
|
||||
class SensorDeviceClass:
|
||||
"""Stand-in namespace for homeassistant.components.sensor.SensorDeviceClass.
|
||||
|
||||
Only the members the integration uses.
|
||||
"""
|
||||
|
||||
PERCENTAGE = "percentage"
|
||||
|
||||
|
||||
class SensorEntity(Entity):
|
||||
"""Stand-in for homeassistant.components.sensor.SensorEntity.
|
||||
|
||||
The ``_attr_*`` passthrough for ``native_value``,
|
||||
``native_unit_of_measurement``, ``state_class``, ``device_class``
|
||||
and ``suggested_display_precision``.
|
||||
"""
|
||||
|
||||
_attr_native_value: object = None
|
||||
_attr_native_unit_of_measurement: str | None = None
|
||||
_attr_state_class: str | None = None
|
||||
_attr_device_class: str | None = None
|
||||
_attr_suggested_display_precision: int | None = None
|
||||
|
||||
@property
|
||||
def native_value(self) -> object:
|
||||
return self._attr_native_value
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
return self._attr_native_unit_of_measurement
|
||||
|
||||
@property
|
||||
def state_class(self) -> str | None:
|
||||
return self._attr_state_class
|
||||
|
||||
@property
|
||||
def device_class(self) -> str | None:
|
||||
return self._attr_device_class
|
||||
|
||||
@property
|
||||
def suggested_display_precision(self) -> int | None:
|
||||
return self._attr_suggested_display_precision
|
||||
|
||||
|
||||
class BinarySensorEntity(Entity):
|
||||
"""Stand-in for homeassistant.components.binary_sensor.BinarySensorEntity.
|
||||
|
||||
Wired now so PLATFORMS can grow (task 4's all-done / present
|
||||
sensors) without more harness surgery; only the surface the
|
||||
integration uses.
|
||||
"""
|
||||
|
||||
_attr_is_on: bool | None = None
|
||||
_attr_device_class: str | None = None
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
return self._attr_is_on
|
||||
|
||||
@property
|
||||
def device_class(self) -> str | None:
|
||||
return self._attr_device_class
|
||||
|
||||
|
||||
class DataUpdateCoordinator:
|
||||
"""Functional stand-in for HA's DataUpdateCoordinator.
|
||||
|
||||
Mirrors the surface NestQuest's coordinator subclass uses:
|
||||
``__init__(hass, logger, name=..., update_interval=...)``,
|
||||
``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``,
|
||||
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):
|
||||
self.hass = hass
|
||||
self.logger = logger
|
||||
self.name = name
|
||||
self.update_interval = update_interval
|
||||
self.data = None
|
||||
self.last_update_success = False
|
||||
self._listeners = []
|
||||
|
||||
def async_add_listener(self, update_callback, context=None):
|
||||
"""Subscribe; returns an unsubscribe callable like HA's."""
|
||||
entry = (update_callback, context)
|
||||
self._listeners.append(entry)
|
||||
|
||||
def _remove():
|
||||
if entry in self._listeners:
|
||||
self._listeners.remove(entry)
|
||||
|
||||
return _remove
|
||||
|
||||
def async_update_listeners(self):
|
||||
"""Notify every listener (entities schedule their writes)."""
|
||||
for callback, _context in list(self._listeners):
|
||||
callback()
|
||||
|
||||
async def async_refresh(self):
|
||||
"""Run one update pass and notify listeners."""
|
||||
try:
|
||||
self.data = await self._async_update_data()
|
||||
self.last_update_success = True
|
||||
except BaseException:
|
||||
self.last_update_success = False
|
||||
raise
|
||||
self.async_update_listeners()
|
||||
|
||||
async def async_config_entry_first_refresh(self):
|
||||
"""First refresh at config-entry setup (alias of refresh)."""
|
||||
await self.async_refresh()
|
||||
|
||||
async def async_shutdown(self):
|
||||
"""Release listeners on teardown."""
|
||||
self._listeners.clear()
|
||||
|
||||
|
||||
_update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator")
|
||||
_update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator
|
||||
_update_coordinator_mock.CoordinatorEntity = CoordinatorEntity
|
||||
|
||||
_entity_mock = _ha_mock("homeassistant.helpers.entity")
|
||||
_entity_mock.DeviceInfo = DeviceInfo
|
||||
_entity_mock.Entity = Entity
|
||||
|
||||
_sensor_mock = _ha_mock("homeassistant.components.sensor")
|
||||
_sensor_mock.SensorEntity = SensorEntity
|
||||
_sensor_mock.SensorDeviceClass = SensorDeviceClass
|
||||
_sensor_mock.SensorStateClass = SensorStateClass
|
||||
|
||||
_binary_sensor_mock = _ha_mock("homeassistant.components.binary_sensor")
|
||||
_binary_sensor_mock.BinarySensorEntity = BinarySensorEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticPathConfig:
|
||||
"""Stand-in mirroring homeassistant.components.http.StaticPathConfig."""
|
||||
|
||||
url_path: str
|
||||
path: str
|
||||
cache_headers: bool = True
|
||||
|
||||
|
||||
def _add_extra_js_url(hass, url, es5=False):
|
||||
"""Stand-in mirroring homeassistant.components.frontend.add_extra_js_url."""
|
||||
urls = getattr(hass, "extra_js_urls", None)
|
||||
if urls is None:
|
||||
hass.extra_js_urls = []
|
||||
urls = hass.extra_js_urls
|
||||
urls.append(url)
|
||||
|
||||
|
||||
_ha_mock("homeassistant.components.http").StaticPathConfig = StaticPathConfig
|
||||
_ha_mock("homeassistant.components.frontend").add_extra_js_url = _add_extra_js_url
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -242,6 +513,163 @@ class ListenerRegistry:
|
||||
return sum(len(listeners) for listeners in self._listeners.values())
|
||||
|
||||
|
||||
class TimeChangeRegistry:
|
||||
"""Models HA's time-change listener bookkeeping.
|
||||
|
||||
``track`` mirrors ``homeassistant.helpers.event.async_track_time_change``:
|
||||
each call appends a record (action + hour/minute/second) and returns
|
||||
a remove callable that reverses the registration. Tests inspect
|
||||
``registrations`` and exercise the newest action through :meth:`fire`.
|
||||
"""
|
||||
|
||||
def __init__(self, hass=None):
|
||||
self._hass = hass
|
||||
self._registrations: list[dict] = []
|
||||
|
||||
def track(self, action, hour=None, minute=None, second=None):
|
||||
record = {
|
||||
"action": action,
|
||||
"hour": hour,
|
||||
"minute": minute,
|
||||
"second": second,
|
||||
}
|
||||
|
||||
def _remove():
|
||||
self._registrations.remove(record)
|
||||
|
||||
record["remove"] = _remove
|
||||
self._registrations.append(record)
|
||||
return _remove
|
||||
|
||||
@property
|
||||
def registrations(self):
|
||||
"""Return a copy of the live registrations."""
|
||||
return list(self._registrations)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""Number of live registrations."""
|
||||
return len(self._registrations)
|
||||
|
||||
async def fire(self, now=None, index: int = -1):
|
||||
"""Run a registered action as HA's scheduler would, awaiting coroutines."""
|
||||
result = self._registrations[index]["action"](now)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
def _async_track_time_change(hass, action, hour=None, minute=None, second=None):
|
||||
"""Mirror homeassistant.helpers.event.async_track_time_change.
|
||||
|
||||
Exact HA 2024.6 signature — no ``local`` keyword: the local-time
|
||||
wrapper is already this function. Routes to the registry attached to
|
||||
``hass`` (created on demand), so tests reach the registrations via
|
||||
``hass.time_change``.
|
||||
"""
|
||||
registry = getattr(hass, "time_change", None)
|
||||
if registry is None:
|
||||
registry = TimeChangeRegistry(hass)
|
||||
hass.time_change = registry
|
||||
return registry.track(action, hour=hour, minute=minute, second=second)
|
||||
|
||||
|
||||
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
|
||||
|
||||
|
||||
class HttpRegistry:
|
||||
"""Models HA's ``hass.http`` static-path registration."""
|
||||
|
||||
def __init__(self):
|
||||
self.static_paths: list[StaticPathConfig] = []
|
||||
|
||||
async def async_register_static_paths(self, configs):
|
||||
self.static_paths.extend(list(configs))
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Models HA's ``hass.bus`` event bus.
|
||||
|
||||
``async_fire`` is SYNCHRONOUS (matching real HA) and records
|
||||
``(event_type, payload)`` pairs in order so tests can assert
|
||||
exactly which events fired with which payloads; there is no
|
||||
listener dispatch (entities poll the coordinator, and the cards
|
||||
are not part of this harness).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.events: list[tuple[str, dict]] = []
|
||||
|
||||
def async_fire(self, event_type: str, payload=None):
|
||||
self.events.append((event_type, dict(payload or {})))
|
||||
|
||||
def fired(self, event_type: str) -> list[dict]:
|
||||
"""Return every payload fired for ``event_type``, in order."""
|
||||
return [
|
||||
payload
|
||||
for fired_type, payload in self.events
|
||||
if fired_type == event_type
|
||||
]
|
||||
|
||||
|
||||
class ServiceRegistry:
|
||||
"""Models HA's ``hass.services`` service registry.
|
||||
|
||||
``async_register`` stores a handler under its (domain, service) key,
|
||||
``has_service`` reports registration, and ``async_remove`` removes it
|
||||
— exactly the surface Home Assistant's ``ServiceRegistry`` exposes
|
||||
(there is deliberately NO ``async_unregister`` alias here, so a caller
|
||||
that uses the wrong method name fails instead of being silently
|
||||
papered over). ``call`` invokes a stored handler the way HA's service
|
||||
bus would — awaiting a coroutine result — so tests can exercise a
|
||||
registered service end-to-end.
|
||||
"""
|
||||
|
||||
def __init__(self, hass=None):
|
||||
self._hass = hass
|
||||
self._services: dict[tuple[str, str], object] = {}
|
||||
|
||||
def async_register(self, domain, service, func, schema=None, supports_response=None):
|
||||
self._services[(domain, service)] = (func, schema)
|
||||
return None
|
||||
|
||||
def has_service(self, domain, service):
|
||||
return (domain, service) in self._services
|
||||
|
||||
def async_remove(self, domain, service):
|
||||
self._services.pop((domain, service), None)
|
||||
return None
|
||||
|
||||
async def call(self, domain, service, data=None, context=None):
|
||||
registered = self._services.get((domain, service))
|
||||
if registered is None:
|
||||
raise KeyError(f"service {domain}.{service} not registered")
|
||||
func, schema = registered
|
||||
payload = dict(data or {})
|
||||
if schema is not None:
|
||||
payload = schema(payload)
|
||||
if context is None:
|
||||
context_obj = SimpleNamespace(user_id=None)
|
||||
elif isinstance(context, dict):
|
||||
context_obj = SimpleNamespace(**context)
|
||||
else:
|
||||
context_obj = context
|
||||
call = SimpleNamespace(
|
||||
domain=domain,
|
||||
service=service,
|
||||
data=payload,
|
||||
context=context_obj,
|
||||
)
|
||||
result = func(call)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
return result
|
||||
|
||||
@property
|
||||
def registered_services(self):
|
||||
"""The registered (domain, service) pairs."""
|
||||
return list(self._services)
|
||||
|
||||
|
||||
def make_config_entry(
|
||||
entry_id: str = "test_entry",
|
||||
options: dict | None = None,
|
||||
@@ -303,6 +731,88 @@ def make_hass() -> tuple:
|
||||
|
||||
hass.async_add_executor_job = _run_executor_job
|
||||
registry = ListenerRegistry(hass)
|
||||
hass.time_change = TimeChangeRegistry(hass)
|
||||
hass.services = ServiceRegistry(hass)
|
||||
hass.bus = EventBus()
|
||||
hass.http = HttpRegistry()
|
||||
hass.extra_js_urls: list[str] = []
|
||||
# A valid IANA time zone (the day-rollover listener reads it to
|
||||
# compute "today" in HA local time).
|
||||
hass.config.time_zone = "UTC"
|
||||
|
||||
# The minimal entity registry: entities self-register through
|
||||
# Entity.async_write_ha_state keyed by unique_id, storing the live
|
||||
# object; tests enumerate created entities and read their state
|
||||
# directly. entities_by_entry mirrors HA's per-entry platform
|
||||
# scoping: unloading an entry's platforms removes exactly that
|
||||
# entry's entities.
|
||||
hass.entities: dict[str, Entity] = {}
|
||||
hass.entities_by_entry: dict[str, dict[str, Entity]] = {}
|
||||
|
||||
def _make_add_entities(entry):
|
||||
entry_entities = hass.entities_by_entry.setdefault(entry.entry_id, {})
|
||||
|
||||
def _async_add_entities(new_entities, update_before_add=False):
|
||||
"""Mirror HA's AddEntitiesCallback: bind hass and register.
|
||||
|
||||
Accepts a single entity or an iterable, mirroring HA's
|
||||
permissive call shape; registration goes through each
|
||||
entity's async_write_ha_state (the mock state write) and
|
||||
is recorded per entry so an unload removes exactly this
|
||||
entry's entities.
|
||||
"""
|
||||
if isinstance(new_entities, Entity):
|
||||
new_entities = [new_entities]
|
||||
for entity in new_entities:
|
||||
entity.hass = hass
|
||||
entity.async_write_ha_state()
|
||||
if entity.unique_id is not None:
|
||||
entry_entities[entity.unique_id] = entity
|
||||
|
||||
return _async_add_entities
|
||||
|
||||
async def _async_forward_entry_setups(entry, platforms):
|
||||
"""Mirror hass.config_entries.async_forward_entry_setups.
|
||||
|
||||
HA's contract, loosely: import each platform module of the
|
||||
entry's integration and drive its ``async_setup_entry`` with a
|
||||
per-entry AddEntitiesCallback. A stub entry without a domain
|
||||
defaults to this harness's single integration (NestQuest).
|
||||
"""
|
||||
for platform in list(platforms):
|
||||
domain = getattr(entry, "domain", "nestquest")
|
||||
module = importlib.import_module(
|
||||
f"custom_components.{domain}.{platform}"
|
||||
)
|
||||
await module.async_setup_entry(
|
||||
hass, entry, _make_add_entities(entry)
|
||||
)
|
||||
hass.config_entries.forwarded_platforms.append(platform)
|
||||
|
||||
async def _async_unload_platforms(entry, platforms):
|
||||
"""Mirror hass.config_entries.async_unload_platforms.
|
||||
|
||||
Runs each platform module's ``async_unload_entry`` when it
|
||||
defines one (per-platform unload is optional, like HA's), then
|
||||
removes the entry's entities — unloading an entry's platforms
|
||||
removes exactly that entry's entities, never another entry's.
|
||||
"""
|
||||
for platform in list(platforms):
|
||||
domain = getattr(entry, "domain", "nestquest")
|
||||
module = importlib.import_module(
|
||||
f"custom_components.{domain}.{platform}"
|
||||
)
|
||||
unload = getattr(module, "async_unload_entry", None)
|
||||
if unload is not None:
|
||||
await unload(hass, entry)
|
||||
hass.config_entries.unloaded_platforms.append(platform)
|
||||
for unique_id in hass.entities_by_entry.pop(entry.entry_id, {}):
|
||||
hass.entities.pop(unique_id, None)
|
||||
|
||||
hass.config_entries.forwarded_platforms: list[str] = []
|
||||
hass.config_entries.unloaded_platforms: list[str] = []
|
||||
hass.config_entries.async_forward_entry_setups = _async_forward_entry_setups
|
||||
hass.config_entries.async_unload_platforms = _async_unload_platforms
|
||||
|
||||
async def _async_reload(entry_id: str) -> None:
|
||||
registry.reloaded.append(entry_id)
|
||||
@@ -320,6 +830,24 @@ def make_hass() -> tuple:
|
||||
return hass, registry
|
||||
|
||||
|
||||
def executor_for(hass):
|
||||
"""Return a dynamic executor callable backed by ``hass.async_add_executor_job``.
|
||||
|
||||
The wrapper resolves ``hass.async_add_executor_job`` on every call, so a
|
||||
test that swaps it after constructing the database (to gate or fail
|
||||
specific jobs) is observed without rebuilding the wrapper — mirroring
|
||||
the pre-extraction db.py, which read the attribute dynamically off
|
||||
``hass``. Use this for cancellation/gating tests that reassign
|
||||
``hass.async_add_executor_job``; plain call sites can pass
|
||||
``hass.async_add_executor_job`` directly.
|
||||
"""
|
||||
|
||||
async def _executor(fn, *args, **kwargs):
|
||||
return await hass.async_add_executor_job(fn, *args, **kwargs)
|
||||
|
||||
return _executor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def hass():
|
||||
"""Provide a standard-shaped hass fixture bound to the running test loop.
|
||||
@@ -389,4 +917,40 @@ def make_config_flow_entry():
|
||||
def _factory(domain: str, entry_id: str = "existing"):
|
||||
return SimpleNamespace(domain=domain, entry_id=entry_id)
|
||||
|
||||
return _factory
|
||||
return _factory
|
||||
|
||||
|
||||
def freeze_nestquest_clock(
|
||||
monkeypatch, instant: datetime.datetime
|
||||
) -> tuple[datetime.datetime, list]:
|
||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||
|
||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched,
|
||||
so the generation path's ``datetime.datetime.now`` reads this instant
|
||||
(localized to whatever time zone it asks for) while every other module
|
||||
and the test harness keep the real clock. Returns ``(frozen, requested)``
|
||||
where ``frozen`` is the instant normalized to UTC and ``requested`` is
|
||||
the list of ``tzinfo`` objects the code under test passed to ``now`` —
|
||||
so a caller can prove the generation path actually requested the
|
||||
configured HA time zone rather than silently reading UTC.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
frozen = instant.astimezone(datetime.timezone.utc)
|
||||
requested: list = []
|
||||
|
||||
class _FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
requested.append(tz)
|
||||
if tz is None:
|
||||
return frozen.replace(tzinfo=None)
|
||||
return frozen.astimezone(tz)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
datetime=_FrozenDatetime,
|
||||
timedelta=datetime.timedelta,
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
@@ -31,7 +31,7 @@ def _make_hass_mock():
|
||||
def _with_db(tmp_path, name):
|
||||
def _run_test(body):
|
||||
async def _main():
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / name)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""FastAPI skeleton tests: health, OpenAPI, core wiring (task 042dfc5c).
|
||||
|
||||
These tests start the NestQuest API app with httpx against the ASGI
|
||||
app and assert the skeleton contract of Feature 16's first task:
|
||||
|
||||
- ``GET /health`` returns 200 with a JSON status body (``status: ok``)
|
||||
and does NOT leak the filesystem path;
|
||||
- ``GET /docs`` (Swagger UI) is served, and ``GET /openapi.json``
|
||||
returns the OpenAPI document;
|
||||
- the app wires core (opens the configured SQLite file and applies
|
||||
migrations on startup via the lifespan) and closes the connection on
|
||||
shutdown (``connected is False`` afterwards);
|
||||
- ``ApiConfig.from_env`` raises when ``NESTQUEST_DB_PATH`` is missing or
|
||||
empty, and returns the value when set;
|
||||
- a startup failure (unwritable path / non-SQLite file) surfaces from
|
||||
the lifespan rather than the first request;
|
||||
- each test is hermetic — it points ``NESTQUEST_DB_PATH`` at a fresh
|
||||
temp file.
|
||||
|
||||
A SEPARATE subprocess test asserts the stronger invariant the task
|
||||
calls out: the app imports the bundled ``core`` WITHOUT importing
|
||||
``homeassistant``. That cannot run inside the pytest process because
|
||||
``tests/conftest.py`` installs mock ``homeassistant`` modules globally
|
||||
before any test imports the integration; a subprocess with a clean
|
||||
``sys.modules`` is the only honest proof (the same approach
|
||||
``tests/test_core_no_ha_smoke.py`` takes for the core bundle).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig, ConfigError
|
||||
from api.database import DatabaseState, _executor, make_database
|
||||
|
||||
#: The repo root, so the no-HA subprocess can import the API package.
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: The marker the no-HA subprocess prints on success.
|
||||
_SUCCESS_MARKER = "API NO-HA IMPORT OK"
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
class _AppRunner:
|
||||
"""Run the app's lifespan around an httpx AsyncClient.
|
||||
|
||||
httpx's :class:`ASGITransport` does NOT run the ASGI lifespan on its
|
||||
own, so the database would never open. Starlette exposes the app's
|
||||
lifespan as ``app.router.lifespan_context``; wrapping the client
|
||||
session in it drives the same startup/shutdown path uvicorn would,
|
||||
so ``open_and_migrate`` runs before the first request and ``close``
|
||||
runs after the last.
|
||||
|
||||
``__aexit__`` uses try/finally so a client-side error still shuts
|
||||
the lifespan down — otherwise a failure mid-test would leak the
|
||||
database connection open.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
# The token value is irrelevant to these tests (the health
|
||||
# route is unauthenticated); it just satisfies the required
|
||||
# field — the panel token's own tests live in test_api_panel.py.
|
||||
self.app = create_app(
|
||||
ApiConfig(db_path=db_path, panel_token="health-runner-token")
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app_client(temp_db_path: str) -> httpx.AsyncClient:
|
||||
async with _AppRunner(temp_db_path) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# --- ApiConfig.from_env -------------------------------------------------
|
||||
|
||||
|
||||
def test_from_env_returns_set_value() -> None:
|
||||
"""Set, non-empty env values are returned verbatim."""
|
||||
cfg = ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": "panel-token",
|
||||
}
|
||||
)
|
||||
assert cfg.db_path == "/data/nq.db"
|
||||
assert cfg.panel_token == "panel-token"
|
||||
|
||||
|
||||
def test_from_env_raises_on_empty_value() -> None:
|
||||
"""An empty/whitespace-only value raises ConfigError (no silent default)."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env({"NESTQUEST_DB_PATH": " "})
|
||||
|
||||
|
||||
def test_from_env_raises_on_missing_value() -> None:
|
||||
"""A missing NESTQUEST_DB_PATH raises ConfigError (no silent default)."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env({})
|
||||
|
||||
|
||||
# --- health / openapi / docs -------------------------------------------
|
||||
|
||||
|
||||
async def test_health_returns_200_with_status_body(
|
||||
app_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""GET /health returns 200 with status ok and no filesystem path."""
|
||||
response = await app_client.get("/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {"status": "ok"}
|
||||
assert "db_path" not in body
|
||||
|
||||
|
||||
async def test_openapi_document_is_served(
|
||||
app_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""GET /openapi.json returns the OpenAPI document."""
|
||||
response = await app_client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
assert schema["openapi"].startswith("3.")
|
||||
assert "/health" in schema["paths"]
|
||||
|
||||
|
||||
async def test_docs_swagger_ui_is_served(app_client: httpx.AsyncClient) -> None:
|
||||
"""GET /docs serves the Swagger UI HTML (OpenAPI at /docs)."""
|
||||
response = await app_client.get("/docs")
|
||||
assert response.status_code == 200
|
||||
assert "swagger" in response.text.lower()
|
||||
|
||||
|
||||
# --- core wiring & shutdown --------------------------------------------
|
||||
|
||||
|
||||
async def test_app_wires_core_and_applies_migrations(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""The lifespan opens the SQLite file and applies migrations.
|
||||
|
||||
After startup, the file exists and carries the NestQuest schema
|
||||
version stamp — proving the app imported core, opened the database
|
||||
at the configured path, and ran the migration runner.
|
||||
"""
|
||||
async with _AppRunner(temp_db_path):
|
||||
assert Path(temp_db_path).exists()
|
||||
# Re-open the file with a fresh core database to read the version
|
||||
# stamp independent of the app's connection. Reuse the API's own
|
||||
# executor so the test exercises the same wiring the app does.
|
||||
from api.nestquest_core import core_db, core_migrations
|
||||
|
||||
database = core_db.NestQuestDatabase(_executor)
|
||||
await database.open(temp_db_path)
|
||||
try:
|
||||
version = await core_migrations.read_schema_version(database)
|
||||
finally:
|
||||
await database.close()
|
||||
assert version >= 1
|
||||
|
||||
|
||||
async def test_shutdown_closes_the_connection(temp_db_path: str) -> None:
|
||||
"""The lifespan closes the database connection on shutdown.
|
||||
|
||||
After the runner exits, ``DatabaseState.database.connected`` is
|
||||
False — proving ``close()`` ran (the lifespan's finally block) even
|
||||
on a clean exit.
|
||||
"""
|
||||
runner = _AppRunner(temp_db_path)
|
||||
async with runner as _client:
|
||||
state: DatabaseState = runner.app.state.db
|
||||
assert state.database.connected
|
||||
assert not state.database.connected
|
||||
|
||||
|
||||
# --- startup failure ----------------------------------------------------
|
||||
|
||||
|
||||
async def test_startup_fails_on_unwritable_path(tmp_path: Path) -> None:
|
||||
"""A database path whose parent directory does not exist fails fast.
|
||||
|
||||
``sqlite3.connect`` raises ``OperationalError`` for a path under a
|
||||
non-existent directory; the lifespan surfaces that as a startup
|
||||
error rather than the first request. The connection is closed
|
||||
through the lifespan's finally block (``close`` is a no-op on a
|
||||
never-opened wrapper).
|
||||
"""
|
||||
bad_path = str(tmp_path / "does_not_exist" / "nestquest.db")
|
||||
runner = _AppRunner(bad_path)
|
||||
with pytest.raises(Exception):
|
||||
async with runner:
|
||||
pass
|
||||
|
||||
|
||||
async def test_startup_fails_on_non_sqlite_file(tmp_path: Path) -> None:
|
||||
"""A path pointing at a non-SQLite file fails fast on startup.
|
||||
|
||||
Opening an existing file that is not a SQLite database makes the
|
||||
``PRAGMA journal_mode=WAL`` run during :meth:`open` raise
|
||||
``DatabaseError: file is not a database``; the lifespan surfaces it.
|
||||
"""
|
||||
bad_file = tmp_path / "not_a_db.bin"
|
||||
bad_file.write_bytes(b"this is not a sqlite database")
|
||||
runner = _AppRunner(str(bad_file))
|
||||
with pytest.raises(Exception):
|
||||
async with runner:
|
||||
pass
|
||||
|
||||
|
||||
# --- no-HA subprocess ---------------------------------------------------
|
||||
|
||||
#: The no-HA subprocess script. Runs with ``sys.executable -c`` so it
|
||||
#: starts with a clean ``sys.modules`` and no ``homeassistant`` installed
|
||||
#: (the suite's conftest stubs are not present in a subprocess). It
|
||||
#: builds the app — which triggers the core bootstrap — and asserts
|
||||
#: neither ``homeassistant`` nor ``custom_components`` entered
|
||||
#: ``sys.modules``, and that the loaded core package's ``__file__``
|
||||
#: points at the bundled directory.
|
||||
_NO_HA_SCRIPT = textwrap.dedent(
|
||||
"""\
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
REPO_ROOT = Path(sys.argv[1])
|
||||
|
||||
# homeassistant must not be present before we touch the API.
|
||||
assert "homeassistant" not in sys.modules, (
|
||||
"homeassistant already in sys.modules before app import — the "
|
||||
"subprocess is not a clean no-HA process"
|
||||
)
|
||||
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig
|
||||
from api.nestquest_core import core
|
||||
|
||||
# The app build imports core via api.nestquest_core. core is a
|
||||
# Home-Assistant-free package; if it (or its loader) ever drags HA
|
||||
# in, this fails.
|
||||
assert "homeassistant" not in sys.modules, (
|
||||
"homeassistant imported while building the NestQuest API app — "
|
||||
"the core wiring is no longer Home-Assistant-free"
|
||||
)
|
||||
# The loader registers core under the distinct name "nestquest_core"
|
||||
# WITHOUT executing custom_components.nestquest.__init__, so the
|
||||
# parent integration package must never be registered, and no
|
||||
# sys.path mutation places the integration directory on sys.path.
|
||||
assert "custom_components" not in sys.modules, (
|
||||
"custom_components registered in sys.modules — the core loader "
|
||||
"executed the integration's HA-coupled __init__ instead of loading "
|
||||
"core by file location"
|
||||
)
|
||||
assert "nestquest_core" in sys.modules, "core was not imported"
|
||||
|
||||
# core.__file__ must point at the bundled directory inside the
|
||||
# integration, proving the file-location loader resolved against the
|
||||
# bundled package rather than some other "core" on sys.path.
|
||||
expected_core_dir = (
|
||||
REPO_ROOT / "custom_components" / "nestquest" / "core"
|
||||
)
|
||||
assert Path(core.__file__).resolve() == (
|
||||
expected_core_dir / "__init__.py"
|
||||
).resolve(), (
|
||||
f"core.__file__={core.__file__} does not point at the bundled "
|
||||
f"directory {expected_core_dir}"
|
||||
)
|
||||
|
||||
# Build the app (the lifespan is NOT run here, so the path is never
|
||||
# opened — it just needs to be a non-empty string to satisfy the
|
||||
# fail-fast config check). Use a temp path so nothing is created;
|
||||
# the panel token value is irrelevant to this import-only check.
|
||||
app = create_app(ApiConfig(db_path=str(
|
||||
Path(tempfile.gettempdir()) / "nq-noha-buildonly.db"
|
||||
), panel_token="noha-import-token"))
|
||||
assert app is not None
|
||||
|
||||
print("API NO-HA IMPORT OK")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_app_imports_core_without_importing_homeassistant() -> None:
|
||||
"""The app imports core without importing homeassistant (subprocess).
|
||||
|
||||
Runs in a subprocess because ``tests/conftest.py`` installs mock
|
||||
``homeassistant`` modules into the pytest process's ``sys.modules``
|
||||
before any test runs, so an in-process check could never
|
||||
distinguish "core never touched HA" from "conftest already stubbed
|
||||
HA for us". The subprocess is a genuine no-HA process; if the
|
||||
core loader (or core itself) ever grows a ``homeassistant``
|
||||
import, the subprocess exits non-zero with ``ModuleNotFoundError``.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", _NO_HA_SCRIPT, str(_REPO_ROOT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"no-HA app-import subprocess exited {result.returncode}.\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
assert _SUCCESS_MARKER in result.stdout, (
|
||||
f"success marker {_SUCCESS_MARKER!r} not printed.\n"
|
||||
f"--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,688 @@
|
||||
"""Panel-plane route tests: service token + snapshot (task ae7e90ae).
|
||||
|
||||
These tests exercise the NestQuest API's panel plane against the ASGI
|
||||
app with httpx:
|
||||
|
||||
- ``GET /api/v1/panel/snapshot`` requires the panel service token
|
||||
(D-012): a request with NO ``Authorization`` header, a MALFORMED
|
||||
header, and a WRONG token all return 401; the correct Bearer token
|
||||
returns 200.
|
||||
- The 200 body is the documented snapshot shape: ``today_iso``,
|
||||
``cycle_day``, and ``children`` in the household's sort order, each
|
||||
carrying ``child_id``/``child_name``/``present``/``next_present``,
|
||||
the per-day rollup counts, and its instances in the documented panel
|
||||
payload shape (ENTITIES-AND-SERVICES.md §1) with missed instances
|
||||
OMITTED (D-009).
|
||||
- ``ApiConfig.from_env`` requires ``NESTQUEST_PANEL_TOKEN`` with the
|
||||
same fail-fast discipline as ``NESTQUEST_DB_PATH``.
|
||||
|
||||
The household is seeded through the SAME core copy the app uses — the
|
||||
``nestquest_core.*`` package registered by ``api/nestquest_core.py`` —
|
||||
NOT the conftest-stubbed ``custom_components.nestquest.core`` copy (see
|
||||
the two-copy caveat in api/nestquest_core.py's docstring: never compare
|
||||
class or exception identity across the two copies). The seed opens and
|
||||
migrates its own connection on the app's SQLite path and closes BEFORE
|
||||
the app's lifespan opens the file, so the two connections never
|
||||
overlap.
|
||||
|
||||
The route's single clock read (:func:`api.routes_panel._local_now`) is
|
||||
pinned with monkeypatch the same way the coordinator's ``_local_now``
|
||||
is pinned in test_core_snapshot.py, so the seeded rows and the
|
||||
snapshot's ``today`` can never straddle midnight.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api import routes_panel
|
||||
from api.app import create_app
|
||||
from api.config import ApiConfig, ConfigError
|
||||
from api.database import _executor
|
||||
from api.nestquest_core import (
|
||||
core_db,
|
||||
core_migrations,
|
||||
core_snapshot,
|
||||
)
|
||||
|
||||
#: The panel service token the test apps are configured with.
|
||||
PANEL_TOKEN = "panel-service-token-test"
|
||||
|
||||
# The bundled core submodules, through the SAME copy the app uses
|
||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
||||
_children = importlib.import_module("nestquest_core.children")
|
||||
_quest_definitions = importlib.import_module(
|
||||
"nestquest_core.quest_definitions"
|
||||
)
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
_recurrence = importlib.import_module("nestquest_core.recurrence")
|
||||
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_completion = importlib.import_module("nestquest_core.completion")
|
||||
|
||||
|
||||
async def _events_for_instance(database: object, instance_id: int) -> list:
|
||||
"""The instance's completion events, on the app's core copy.
|
||||
|
||||
Uses the SAME ``nestquest_core.*`` copy the app uses (the two-copy
|
||||
caveat: never mix copies) against the app's live connection.
|
||||
"""
|
||||
dao = _dao_instances.CompletionEventsDao(database)
|
||||
return await dao.list_by_instance(instance_id)
|
||||
|
||||
|
||||
async def _derived_state(
|
||||
database: object, instance: object, today: datetime.date
|
||||
) -> str:
|
||||
"""The instance's derived state on the app's core copy.
|
||||
|
||||
``derive_state(instance, latest, today)`` from the app's core copy;
|
||||
the latest event is fetched through the same copy's DAO against the
|
||||
app's live connection.
|
||||
"""
|
||||
dao = _dao_instances.CompletionEventsDao(database)
|
||||
latest = await dao.get_latest_for_instance(instance.id)
|
||||
return _completion.derive_state(instance, latest, today)
|
||||
|
||||
|
||||
async def _seed_household(
|
||||
db_path: str,
|
||||
today: datetime.date,
|
||||
pinned_now: datetime.datetime,
|
||||
) -> SimpleNamespace:
|
||||
"""Seed a two-child household on ``db_path`` and return its records.
|
||||
|
||||
Mirrors the test_core_snapshot.py household: an AWAY child (2-week
|
||||
custody cycle anchored yesterday, empty week 0, an override returns
|
||||
her today+2) with an open quest due 10:00 — overdue at the pinned
|
||||
12:00 — and a present child whose 17:00 quest is completed at
|
||||
12:00 (on time). A third quest's instance is materialized due
|
||||
YESTERDAY (via ``materialize`` with ``today`` pinned to yesterday,
|
||||
since the no-past guard refuses past-dated writes): a past-due OPEN
|
||||
instance, i.e. the "missed" view the panel payload must omit.
|
||||
|
||||
Opens, migrates, seeds, and CLOSES its own connection so the app's
|
||||
lifespan connection never overlaps it.
|
||||
"""
|
||||
database = core_db.NestQuestDatabase(_executor)
|
||||
await database.open(db_path)
|
||||
try:
|
||||
await core_migrations.apply_migrations(database)
|
||||
today_iso = today.isoformat()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
yesterday_iso = yesterday.isoformat()
|
||||
|
||||
ada = await _children.create_child(database, "Ada", sort_order=0)
|
||||
bo = await _children.create_child(database, "Bo", sort_order=1)
|
||||
|
||||
# Every rule starts YESTERDAY so the yesterday materialization
|
||||
# below (which pins ``today`` to yesterday) fires them.
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Pack bag",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "10:00")],
|
||||
)
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Brush teeth",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
||||
),
|
||||
[bo.id],
|
||||
[("morning", "17:00")],
|
||||
)
|
||||
# Today's instances first, while Ada has no presence schedule
|
||||
# (she is present every day, so her instance IS generated).
|
||||
await _materialize.materialize(
|
||||
database,
|
||||
today_iso,
|
||||
(today + datetime.timedelta(days=3)).isoformat(),
|
||||
today=today,
|
||||
)
|
||||
# The stale quest is created AFTER the today walk, so its only
|
||||
# instance is the yesterday one written below.
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Stale chore",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "09:00")],
|
||||
)
|
||||
# Materialize yesterday with ``today`` pinned to yesterday: the
|
||||
# no-past guard accepts the rows, and they land past-due (no
|
||||
# completion events) — the missed view.
|
||||
await _materialize.materialize(
|
||||
database, yesterday_iso, yesterday_iso, today=yesterday
|
||||
)
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
|
||||
ada_today = await instances.list_by_child_and_date(ada.id, today_iso)
|
||||
assert len(ada_today) == 1
|
||||
pack_bag_instance = ada_today[0]
|
||||
bo_today = await instances.list_by_child_and_date(bo.id, today_iso)
|
||||
assert len(bo_today) == 1
|
||||
brush_teeth_instance = bo_today[0]
|
||||
ada_yesterday = await instances.list_by_child_and_date(
|
||||
ada.id, yesterday_iso
|
||||
)
|
||||
stale_instance = next(
|
||||
record
|
||||
for record in ada_yesterday
|
||||
if record.due_time == "09:00"
|
||||
)
|
||||
|
||||
# Ada's custody schedule, added AFTER materialization (the
|
||||
# same order test_core_snapshot.py seeds): 2-week cycle
|
||||
# anchored yesterday, empty week 0 covers today => absent
|
||||
# today; an override returns her on today+2.
|
||||
await _dao_presence.PresenceSchedulesDao(database).upsert_by_child(
|
||||
ada.id,
|
||||
cycle_length_weeks=2,
|
||||
anchor_date=yesterday_iso,
|
||||
pattern="|0,1,2,3,4,5,6",
|
||||
)
|
||||
returns_date = today + datetime.timedelta(days=2)
|
||||
await _dao_presence.PresenceOverridesDao(database).create(
|
||||
ada.id,
|
||||
returns_date.isoformat(),
|
||||
returns_date.isoformat(),
|
||||
is_present=True,
|
||||
note="visit",
|
||||
)
|
||||
|
||||
# Complete Bo's today instance at the pinned 12:00 (17:00 due
|
||||
# => on time).
|
||||
await _completion.complete_instance(
|
||||
database,
|
||||
brush_teeth_instance.id,
|
||||
actor_source="panel",
|
||||
actor_child_id=bo.id,
|
||||
now=pinned_now,
|
||||
today=today,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
ada=ada,
|
||||
bo=bo,
|
||||
pack_bag_instance=pack_bag_instance,
|
||||
brush_teeth_instance=brush_teeth_instance,
|
||||
stale_instance=stale_instance,
|
||||
returns_date=returns_date,
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
class _PanelRunner:
|
||||
"""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.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, db_path: str, panel_token: str = PANEL_TOKEN
|
||||
) -> None:
|
||||
self.app = create_app(
|
||||
ApiConfig(db_path=db_path, panel_token=panel_token)
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> tuple["_PanelRunner", 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",
|
||||
)
|
||||
await self._client.__aenter__()
|
||||
return self, self._client
|
||||
|
||||
async def __aexit__(self, *exc) -> None:
|
||||
try:
|
||||
await self._client.__aexit__(*exc)
|
||||
finally:
|
||||
await self._lifespan.__aexit__(*exc)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def panel_client(temp_db_path: str) -> httpx.AsyncClient:
|
||||
"""An app client with an empty (migrated) household database."""
|
||||
async with _PanelRunner(temp_db_path) as runner_and_client:
|
||||
_, client = runner_and_client
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_panel(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
||||
"""A seeded household served by the app, with the clock pinned.
|
||||
|
||||
One host clock read anchors ``today``; the pinned instant is
|
||||
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). The
|
||||
route's ``_local_now`` is patched to return the pinned instant.
|
||||
|
||||
Yields the runner too so tests reach the app's live database (the
|
||||
SAME ``nestquest_core`` connection) for assertions.
|
||||
"""
|
||||
today = datetime.datetime.now().astimezone().date()
|
||||
time_zone = ZoneInfo("UTC")
|
||||
pinned_now = datetime.datetime(
|
||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone
|
||||
)
|
||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
|
||||
async with _PanelRunner(temp_db_path) as runner_and_client:
|
||||
runner, client = runner_and_client
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=runner.app.state.db.database,
|
||||
seed=seed,
|
||||
today=today,
|
||||
pinned_now=pinned_now,
|
||||
pinned_utc_iso=pinned_now.astimezone(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
# --- ApiConfig.from_env: the panel token --------------------------------
|
||||
|
||||
|
||||
def test_from_env_reads_panel_token() -> None:
|
||||
"""A set, non-empty NESTQUEST_PANEL_TOKEN is returned verbatim."""
|
||||
cfg = ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": "panel-token",
|
||||
}
|
||||
)
|
||||
assert cfg.panel_token == "panel-token"
|
||||
|
||||
|
||||
def test_from_env_requires_panel_token_when_missing() -> None:
|
||||
"""A missing panel token raises ConfigError (no silent default)."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env({"NESTQUEST_DB_PATH": "/data/nq.db"})
|
||||
|
||||
|
||||
def test_from_env_requires_panel_token_when_empty() -> None:
|
||||
"""An empty/whitespace panel token raises ConfigError."""
|
||||
with pytest.raises(ConfigError):
|
||||
ApiConfig.from_env(
|
||||
{
|
||||
"NESTQUEST_DB_PATH": "/data/nq.db",
|
||||
"NESTQUEST_PANEL_TOKEN": " ",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --- service-token auth --------------------------------------------------
|
||||
|
||||
|
||||
async def test_missing_token_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A request with no Authorization header returns 401."""
|
||||
response = await panel_client.get("/api/v1/panel/snapshot")
|
||||
assert response.status_code == 401
|
||||
assert "www-authenticate" in {
|
||||
key.lower() for key in response.headers
|
||||
}
|
||||
|
||||
|
||||
async def test_wrong_token_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A wrong Bearer token returns 401."""
|
||||
response = await panel_client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": "Bearer not-the-panel-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_malformed_authorization_header_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A malformed Authorization header returns 401.
|
||||
|
||||
Wrong scheme, empty credential, and an empty header all fail the
|
||||
same way — the response never reveals which part failed.
|
||||
"""
|
||||
for header in (
|
||||
"Basic dXNlcjpwYXNz",
|
||||
"Bearer",
|
||||
"Bearer ",
|
||||
"Token not-the-panel-token",
|
||||
"",
|
||||
):
|
||||
response = await panel_client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": header},
|
||||
)
|
||||
assert response.status_code == 401, f"header {header!r} passed"
|
||||
|
||||
|
||||
# --- snapshot payload ----------------------------------------------------
|
||||
|
||||
|
||||
async def test_valid_token_returns_full_snapshot(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The correct token returns 200 with the documented snapshot shape.
|
||||
|
||||
Children in sort order (Ada sort_order 0, Bo 1); per-child counts,
|
||||
presence, next_present, and cycle day all asserted as explicit
|
||||
literals; each child's instances are in the documented panel
|
||||
payload shape (done -> completed spelling, on_time passthrough).
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
assert body["today_iso"] == seeded_panel.today.isoformat()
|
||||
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
|
||||
assert body["cycle_day"] == 2
|
||||
|
||||
children = body["children"]
|
||||
assert [child["child_id"] for child in children] == [
|
||||
seed.ada.id,
|
||||
seed.bo.id,
|
||||
], "children appear in sort_order"
|
||||
|
||||
ada, bo = children
|
||||
|
||||
# Ada: away today, returns on the override date; her 10:00 quest
|
||||
# is open and overdue at the pinned 12:00.
|
||||
assert ada["child_name"] == "Ada"
|
||||
assert ada["present"] is False
|
||||
assert ada["next_present"] == seed.returns_date.isoformat()
|
||||
assert ada["due_today"] == 1
|
||||
assert ada["completed_today"] == 0
|
||||
assert ada["remaining_today"] == 1
|
||||
assert ada["completion_pct"] == 0
|
||||
assert ada["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,
|
||||
}
|
||||
]
|
||||
|
||||
# Bo: present, completed his 17:00 quest at the pinned 12:00
|
||||
# (on time), so the panel spelling is "completed".
|
||||
assert bo["child_name"] == "Bo"
|
||||
assert bo["present"] is True
|
||||
assert bo["next_present"] is None
|
||||
assert bo["due_today"] == 1
|
||||
assert bo["completed_today"] == 1
|
||||
assert bo["remaining_today"] == 0
|
||||
assert bo["completion_pct"] == 100
|
||||
assert bo["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": seeded_panel.pinned_utc_iso,
|
||||
"on_time": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_missed_instances_are_omitted_from_panel_payload(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A missed instance never reaches the panel payload (D-009).
|
||||
|
||||
The household carries a past-due OPEN instance due yesterday (the
|
||||
state the panel reads as "missed"); it must not appear anywhere in
|
||||
the response, and Ada's payload carries exactly her ONE today
|
||||
instance — the past-due rows (the stale chore AND her pack bag due
|
||||
yesterday) are both omitted rather than rendered.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.get(
|
||||
"/api/v1/panel/snapshot",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
payload_instances = [
|
||||
instance
|
||||
for child in body["children"]
|
||||
for instance in child["instances"]
|
||||
]
|
||||
assert "missed" not in [
|
||||
instance["state"] for instance in payload_instances
|
||||
]
|
||||
past_due_ids = {seed.stale_instance.id}
|
||||
assert past_due_ids.isdisjoint(
|
||||
instance["id"] for instance in payload_instances
|
||||
), "the past-due open instance leaked into the panel payload"
|
||||
|
||||
ada = next(
|
||||
child
|
||||
for child in body["children"]
|
||||
if child["child_id"] == seed.ada.id
|
||||
)
|
||||
assert [instance["id"] for instance in ada["instances"]] == [
|
||||
seed.pack_bag_instance.id
|
||||
]
|
||||
|
||||
|
||||
def test_panel_shaper_drops_missed_views() -> None:
|
||||
"""The route's shaper omits a missed view (D-009), on the app's copy.
|
||||
|
||||
``instance_payload(include_missed=False)`` is what the snapshot
|
||||
route shapes every child's instances with; exercised here on the
|
||||
SAME core copy the app uses (the two-copy caveat makes this a
|
||||
distinct object from the conftest-stubbed copy's identical source).
|
||||
"""
|
||||
missed_view = core_snapshot.QuestInstanceView(
|
||||
instance_id=999,
|
||||
definition_id=1,
|
||||
child_id=1,
|
||||
title="Stale chore",
|
||||
icon=None,
|
||||
window="morning",
|
||||
due_date="2026-09-21",
|
||||
due_time="09:00",
|
||||
state="missed",
|
||||
overdue=False,
|
||||
completed_at=None,
|
||||
was_on_time=None,
|
||||
)
|
||||
assert core_snapshot.instance_payload(
|
||||
[missed_view], include_missed=False
|
||||
) == []
|
||||
|
||||
|
||||
# --- route documentation -------------------------------------------------
|
||||
|
||||
|
||||
async def test_snapshot_route_is_documented(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""The snapshot route and its response shape are in the OpenAPI doc."""
|
||||
response = await panel_client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
assert "/api/v1/panel/snapshot" in schema["paths"]
|
||||
assert "get" in schema["paths"]["/api/v1/panel/snapshot"]
|
||||
documented = {
|
||||
model["title"]
|
||||
for model in schema["components"]["schemas"].values()
|
||||
}
|
||||
assert "PanelSnapshotResponse" in documented
|
||||
assert "PanelChildPayload" in documented
|
||||
assert "PanelInstancePayload" in documented
|
||||
|
||||
|
||||
# --- complete route -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_complete_writes_panel_completion(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The first POST completes the instance as the tapped profile.
|
||||
|
||||
Returns success; the completion event recorded is
|
||||
``actor_source='panel'`` with the tapped ``actor_child_id`` and NO
|
||||
``actor_user_id`` (the panel actor contract), and the instance's
|
||||
derived state becomes ``done``.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.ada.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "done"}
|
||||
|
||||
# Assert the recorded event and derived state through the SAME core
|
||||
# copy the app uses (two-copy caveat: never mix copies), against the
|
||||
# app's live connection.
|
||||
events = await _events_for_instance(
|
||||
seeded_panel.database, seed.pack_bag_instance.id
|
||||
)
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.event_type == "completed"
|
||||
assert event.actor_source == "panel"
|
||||
assert event.actor_child_id == seed.ada.id
|
||||
assert event.actor_user_id is None
|
||||
|
||||
instance = await _dao_instances.QuestInstancesDao(
|
||||
seeded_panel.database
|
||||
).get_by_id(seed.pack_bag_instance.id)
|
||||
assert await _derived_state(
|
||||
seeded_panel.database, instance, seeded_panel.today
|
||||
) == "done"
|
||||
|
||||
|
||||
async def test_complete_is_idempotent(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A second call is a no-op that still returns success.
|
||||
|
||||
The core completion layer's ``appended`` flag decides: the repeat
|
||||
call appends nothing, so exactly ONE completion event row exists
|
||||
after two calls.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
headers = {"Authorization": f"Bearer {PANEL_TOKEN}"}
|
||||
url = f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete"
|
||||
body = {"actor_child_id": seed.ada.id}
|
||||
first = await seeded_panel.client.post(url, headers=headers, json=body)
|
||||
assert first.status_code == 200
|
||||
second = await seeded_panel.client.post(url, headers=headers, json=body)
|
||||
assert second.status_code == 200
|
||||
assert second.json() == {"status": "done"}
|
||||
|
||||
events = await _events_for_instance(
|
||||
seeded_panel.database, seed.pack_bag_instance.id
|
||||
)
|
||||
assert len(events) == 1, (
|
||||
"the idempotent call must not append a second event"
|
||||
)
|
||||
|
||||
|
||||
async def test_complete_unknown_instance_is_not_found(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""Completing a non-existent instance returns 404."""
|
||||
response = await panel_client.post(
|
||||
"/api/v1/panel/instances/999999/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": 1},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_complete_without_token_is_unauthorized(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""A complete request with no Authorization header returns 401."""
|
||||
response = await panel_client.post(
|
||||
"/api/v1/panel/instances/1/complete",
|
||||
json={"actor_child_id": 1},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_complete_wrong_child_is_not_found(
|
||||
seeded_panel: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Tapping the wrong child's profile answers 404, not success.
|
||||
|
||||
``complete_instance`` does not itself check that the tapped
|
||||
``actor_child_id`` is the child the instance belongs to; the route
|
||||
guards it so a wrong-profile tap can never complete another
|
||||
child's quest.
|
||||
"""
|
||||
seed = seeded_panel.seed
|
||||
response = await seeded_panel.client.post(
|
||||
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
json={"actor_child_id": seed.bo.id},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
# And nothing was completed.
|
||||
events = await _events_for_instance(
|
||||
seeded_panel.database, seed.pack_bag_instance.id
|
||||
)
|
||||
assert events == []
|
||||
|
||||
|
||||
async def test_complete_route_is_documented(
|
||||
panel_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""The complete route and its request shape are in the OpenAPI doc."""
|
||||
response = await panel_client.get("/openapi.json")
|
||||
assert response.status_code == 200
|
||||
schema = response.json()
|
||||
path = "/api/v1/panel/instances/{instance_id}/complete"
|
||||
assert path in schema["paths"]
|
||||
assert "post" in schema["paths"][path]
|
||||
documented = {
|
||||
model["title"]
|
||||
for model in schema["components"]["schemas"].values()
|
||||
}
|
||||
assert "PanelCompleteRequest" in documented
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user