Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2492 lines
100 KiB
Python
2492 lines
100 KiB
Python
"""Admin-plane routes for the NestQuest API service.
|
|
|
|
The admin plane (``/api/v1/admin/*``, D-012) serves the admin PWA.
|
|
Every route on :data:`router` requires a valid Authentik OIDC JWT
|
|
whose ``groups`` claim names ``nestquest-admins``, through the ONE
|
|
reusable dependency :func:`api.auth.require_admin` (declared once at
|
|
the router level, so each later admin route added to this router
|
|
inherits the check). The panel service token is refused outright on
|
|
this plane (403), never treated as a JWT.
|
|
|
|
The children routes are THIN adapters over the bundled core (Feature
|
|
15): each handler validates the request's shape with a Pydantic model,
|
|
resolves the live database off ``app.state.db``, calls ONE
|
|
:mod:`nestquest_core.children` business function — the name policy,
|
|
the no-explicit-NULL rule, the real-bool check and the permutation
|
|
check all live THERE, never in a handler — and serializes the returned
|
|
:class:`~nestquest_core.dao_children.ChildRecord` into the documented
|
|
payload (``id``, ``display_name``, ``colour``, ``avatar_ref``,
|
|
``sort_order``, ``is_active``).
|
|
|
|
The quest-definition routes follow the same pattern over
|
|
:mod:`nestquest_core.quest_definitions`: the body model carries the
|
|
shape, the handler builds ONE
|
|
:class:`~nestquest_core.recurrence.ScheduleRule` from the request's
|
|
``rule`` object through the model's own ``from_dict`` (rule validation
|
|
is the MODEL's construction-time job — a bad combination raises
|
|
:class:`~nestquest_core.recurrence.RuleValidationError`, never
|
|
hand-rolled here), and the call goes to
|
|
``create_quest_definition`` / ``edit_quest_definition`` /
|
|
``set_quest_definition_active``. The title policy, the window-name
|
|
and due-time policy, the assignee checks, the whole-set window
|
|
replacement and the never-hard-delete policy (deactivation is the only
|
|
removal path) all live in the core. The edit route forwards only
|
|
SUPPLIED fields (``exclude_unset``), like the children edit; a supplied
|
|
``assignee_child_ids`` list REPLACES the whole assignee set inside the
|
|
core edit's one transaction (and its one regeneration).
|
|
|
|
The presence routes follow the same pattern over
|
|
:mod:`nestquest_core.presence_management` — the ONE presence write
|
|
layer the HA services share, so the two planes cannot drift:
|
|
|
|
- ``GET /children/{child_id}/presence-patterns`` lists the child's
|
|
repeating presence patterns (zero or more, schema 9) through ONE
|
|
``list_presence_patterns`` call, wrapped as ``{"patterns": [...]}``
|
|
ordered by id — an empty list when the child has none (present
|
|
every day), never a 404; an unknown child is 404.
|
|
- ``POST /children/{child_id}/presence-patterns`` adds one pattern:
|
|
the body's ``name``, ``kind`` (``home``/``away``),
|
|
``cycle_length_weeks``, ``anchor_date`` and ``pattern`` go straight
|
|
into ONE :class:`~nestquest_core.presence.PresencePattern` (the model
|
|
validates the name, the kind, cycle length, the strict anchor date
|
|
and the pattern's week coverage at construction), the core inserts
|
|
it beside the child's other patterns and regenerates the child's
|
|
future instances; the response (201) is the STORED pattern with its
|
|
``id``, its weekday sets decoded back through the model.
|
|
- ``PATCH /presence-patterns/{pattern_id}`` edits one pattern: only
|
|
SUPPLIED fields are forwarded (``exclude_unset``), the core lays them
|
|
over the stored pattern and rebuilds the WHOLE model, so a partial
|
|
edit is validated exactly as a create; it regenerates the pattern's
|
|
child.
|
|
- ``DELETE /presence-patterns/{pattern_id}`` deletes one pattern and
|
|
regenerates its child. It answers ``{"status": "ok"}``.
|
|
- ``POST /presence-overrides`` creates one date-range override: the
|
|
body becomes ONE :class:`~nestquest_core.presence.PresenceOverride`
|
|
(the model validates the dates, ``end >= start``, the real bool and
|
|
the note), the core creates it — rejecting a range that overlaps the
|
|
same child's existing override — and regenerates; the response is
|
|
the stored record with its ``id``.
|
|
- ``DELETE /presence-overrides/{override_id}`` deletes one override
|
|
(children are never hard-deleted; overrides are deletable by
|
|
design) and regenerates that override's child. It answers
|
|
``{"status": "ok"}``.
|
|
- ``POST /presence-overrides/consequence`` previews an override
|
|
BEFORE it is saved: ONE ``preview_presence_override_consequence``
|
|
call answers ``{removed: N}``, the number of the child's open
|
|
upcoming instances the materialization walk would stop generating
|
|
(instances with a completion event are never counted, D-005).
|
|
Nothing is written; an unknown child is 404, a malformed or inverted
|
|
range 422.
|
|
- ``GET /presence-overrides`` lists the household's overrides through
|
|
ONE ``list_presence_overrides`` call, wrapped as
|
|
``{"overrides": [...]}`` in the POST response's shape, ordered by
|
|
start date, child id, id; the optional ``child_id``, ``start`` and
|
|
``end`` query filters (strict dates, ``end >= start``) are the
|
|
core's to validate.
|
|
|
|
The uncomplete and regenerate routes (task da0226b3) follow the same
|
|
pattern over :mod:`nestquest_core.completion` and
|
|
:mod:`nestquest_core.materialize`:
|
|
|
|
- ``POST /instances/{instance_id}/uncomplete`` reverses ONE completion:
|
|
the handler takes the actor from the VERIFIED JWT (``actor_source``
|
|
``'user'`` with ``actor_user_id`` = the token's ``sub`` — the D-008
|
|
user-actor shape, so the reversal is attributed to the admin who
|
|
called it) and calls :func:`nestquest_core.completion.uncomplete_instance`.
|
|
The append-only discipline lives in the core: a reversal APPENDS an
|
|
``uncompleted`` event after the original ``completed`` row — the
|
|
original is never edited or deleted — and the instance's derived
|
|
state (recomputed from the latest event) returns to ``open``. On an
|
|
ACTUAL reversal (``CompletionResult.appended`` True) the route
|
|
publishes the ``nestquest_quest_uncompleted`` transition through
|
|
:func:`api.transitions.publish_quest_uncompleted` (the payload built
|
|
by the core builder); an already-open no-op publishes nothing. The
|
|
response carries the derived ``state`` and whether THIS call
|
|
``appended``.
|
|
- ``POST /regenerate`` re-materializes the rolling horizon through the
|
|
same core path the integration's config/presence changes use. The
|
|
body names ONE scope: ``household`` (the default —
|
|
:func:`nestquest_core.materialize.materialize` over
|
|
``[today, today + const.DEFAULT_HORIZON_DAYS]``, the core's default
|
|
horizon; the API config carries no horizon knob of its own),
|
|
``definition`` (:func:`nestquest_core.materialize.regenerate_for_definition`
|
|
— deletes the definition's open future instances and re-runs the
|
|
walk), or ``child``
|
|
(:func:`nestquest_core.materialize.regenerate_for_child`).
|
|
The upsert is idempotent, so re-running never duplicates an
|
|
instance. The response carries the scope, the scoped id (if any)
|
|
and the core's upsert ``count``.
|
|
|
|
The history routes (task 2d2dda53) follow the same pattern over
|
|
:mod:`nestquest_core.history` — the ONE read-only history layer, which
|
|
owns every rule so the two routes cannot drift:
|
|
|
|
- ``GET /history?filter=all|reversals|missed&start=YYYY-MM-DD&end=YYYY-MM-DD``
|
|
returns the history rows for ONE filter over the CLOSED due-date
|
|
range ``[start, end]``: ``all`` (every completion event — completed
|
|
AND uncompleted — whose instance is due in the range), ``reversals``
|
|
(the uncompleted events only) and ``missed`` (the DERIVED past-due
|
|
open instances, computed by the core's own derive-state rule —
|
|
``missed`` is never a stored event_type and nothing is appended).
|
|
The range scopes by the instance's ``due_date`` (the Feature 13
|
|
rule: history asks what happened to the tasks due that day), not the
|
|
event's wall-clock stamp. The filter and date-range policies live
|
|
in the core (strict ``YYYY-MM-DD``, ``end >= start``, a
|
|
``ValueError`` naming the offending field); the handler is a thin
|
|
adapter that threads ONE clock read (:func:`_local_now`, the missed
|
|
derivation's ``today``) and serializes the core's rows.
|
|
- ``GET /history.csv?...`` exports the SAME filtered range as CSV:
|
|
ONE :func:`nestquest_core.history.export_history_csv` call — the
|
|
same query path the JSON route uses — answered as ``text/csv`` with
|
|
a ``Content-Disposition`` attachment filename and the documented,
|
|
stable header row (Admin spec §5 event-row fields).
|
|
|
|
The snapshot route (task ef3f4877) is the admin-plane twin of the
|
|
panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab —
|
|
the PWA is internet-facing and never holds the panel service token
|
|
(D-012), so it reads the same snapshot here:
|
|
|
|
- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into
|
|
:func:`nestquest_core.snapshot.build_snapshot` and shapes each
|
|
child's instances through
|
|
:func:`nestquest_core.snapshot.instance_payload` with
|
|
``include_missed=True`` — admin payloads KEEP missed instances with
|
|
their ``missed`` state (D-009), where the panel omits them. The
|
|
body is the panel's documented shape (``today_iso``, ``cycle_day``,
|
|
``children`` with the per-child presence, rollup counts and
|
|
``instances``); the builder, the state derivation and the shaping
|
|
all live in the core.
|
|
|
|
The settings routes (task 2b3de7e5) follow the same pattern over
|
|
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
|
|
store. The API now OWNS settings in the database (a validated JSON
|
|
document in the existing ``nestquest_meta_state`` key/value table); the
|
|
integration's Home-Assistant config-entry options remain a temporary,
|
|
SEPARATE source until Feature 18 wires the client to the API — the two
|
|
are deliberately not unified here:
|
|
|
|
- ``GET /settings`` returns the current effective settings: all eleven
|
|
documented fields (the rolling horizon, the day rollover time, the
|
|
notify target, the three notification times, the four enable
|
|
toggles, and the ``timezone`` — the IANA household zone the
|
|
household-local dates derive from, empty meaning host local; only an
|
|
admin's ``PATCH`` ever sets it) — the defaults on a fresh database —
|
|
plus the read-only ``effective_timezone`` (:mod:`api.host_zone`): the
|
|
zone the API actually reads its clock in, which the admin app formats
|
|
local time in.
|
|
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
|
|
JSON object of settings field names to new values, passed STRAIGHT
|
|
to :func:`nestquest_core.settings_store.update_settings` — the
|
|
unknown-field rejection, the per-field validation (the core settings
|
|
validators: a positive int, strict ``HH:MM`` strings, real booleans)
|
|
and the validate-everything-before-write atomicity all live in the
|
|
core, so a rejected update never changes the stored settings. A
|
|
JSON ``null`` value resets its field to the default (the settings
|
|
module's uniform None-as-absent rule). The response is the updated
|
|
effective settings, the same shape ``GET`` returns.
|
|
|
|
The missed-sweep trigger (task 058c7b69) follows the same pattern over
|
|
:mod:`nestquest_core.sweep` — the HA-free sweep policy the
|
|
integration's rollover listener shares:
|
|
|
|
- ``POST /missed-sweep`` runs the sweep for the household-local
|
|
``today`` (ONE :func:`_local_now` clock read re-expressed in the
|
|
configured household ``timezone`` — empty falls back to host local —
|
|
threaded into the core call) and publishes each returned
|
|
``(event_type, payload)`` pair DIRECTLY on the app's ONE transition
|
|
publisher — the sweep-built payload, never re-fetched (the core
|
|
commits its watermark before returning, so a failed re-fetch after it
|
|
would lose the missed event forever), the same
|
|
publish-what-the-core-built shape api/scheduler.py uses. The
|
|
watermark rule (a same-day rerun announces nothing, a post-downtime
|
|
run sweeps the accumulated window once), the no-completion-event rule
|
|
and the read-only-over-the-domain-tables guarantee all live in the
|
|
core; the response reports how many transitions were published.
|
|
|
|
The quest-definition DELETE route (task 3efb07b4) is the hybrid admin
|
|
delete (owner decision 2026-09-26: "delete if there's no history,
|
|
retire/deactivate if there is"):
|
|
|
|
- ``DELETE /quest-definitions/{definition_id}`` is a thin adapter over
|
|
:func:`nestquest_core.quest_definitions.delete_quest_definition`. A
|
|
definition with NO completion event on any instance is hard-deleted
|
|
(instances, assignees, windows, the row and its unreferenced rule, in
|
|
one core transaction); one WITH history is retired by deactivation.
|
|
Retirement preserves the definition row, schedule rule, assignees,
|
|
windows and ALL durable completion history; like any deactivation it
|
|
regenerates, so future UNCOMPLETED instances are cleared (rolling,
|
|
regenerable rows) and none are recreated while the definition is
|
|
inactive — the retired task stops generating and shows no open work.
|
|
Durable instance history lives only in the append-only
|
|
``completion_events``. The response reports which happened
|
|
(``deleted`` / ``retired``) and carries the retired definition, or
|
|
``null`` when deleted. An unknown id is 404.
|
|
|
|
The occurrence-preview route (task 5f843564) lets the admin PWA's
|
|
Definitions edit sheet show the next dates a rule fires on WITHOUT
|
|
reimplementing the recurrence rules client-side:
|
|
|
|
- ``POST /quest-definitions/occurrences-preview`` takes the SAME
|
|
``rule`` object create/edit take (:class:`AdminRuleRequest`, built
|
|
through the SAME :func:`_rule_from_request` / core ``from_dict``
|
|
validation, so a rejected rule is 422 exactly as on create), an
|
|
optional ``start_date`` (default: the household-local ``today`` —
|
|
ONE :func:`_local_now` clock read re-expressed in the configured
|
|
household ``timezone``, empty falling back to host local) and an
|
|
optional ``count`` (1..50, default 10). The dates come from ONE
|
|
:func:`nestquest_core.recurrence.occurrences_between` call — the
|
|
same engine the materializer walks — so the preview can only agree
|
|
with what would be generated. The scan is BOUNDED server-side: the
|
|
window is ``[start_date, start_date + 366 days]`` inclusive,
|
|
whatever the caller sends, so every rule shape (a yearly rule
|
|
included) shows at least its next occurrence, and a caller can never
|
|
ask for an unbounded walk. Any strict ``start_date`` is accepted
|
|
rather than clamped or range-limited — the fixed window already
|
|
bounds the work — EXCEPT one whose window would reach 9999-12-31,
|
|
which is 422 (the core's day walk cannot step past the calendar's
|
|
last date). ``start_date`` strictness
|
|
(``YYYY-MM-DD``) is the core's own date validator; a non-strict or
|
|
unparseable value is 422. A ``count`` outside 1..50 is the body
|
|
model's 422 before any handler code runs. Nothing is read or
|
|
written: the route touches no database.
|
|
|
|
Error mapping (every children, quest-definition and presence route,
|
|
deliberately narrow):
|
|
|
|
- Malformed input the body model or the path parser rejects (a missing
|
|
or wrong-typed field, a non-integer child or definition id) is
|
|
FastAPI's 422 before any handler code runs.
|
|
- A core ``ValueError`` reporting a NON-EXISTENT child (edit or
|
|
set_active on an unknown id) is mapped to 404.
|
|
- A core ``ValueError`` reporting a NON-EXISTENT DEFINITION (edit or
|
|
set_active on an unknown id — the core prefixes that error with
|
|
``definition_id:``) is mapped to 404. Create's and edit's
|
|
rejected-assignee error names a child from the BODY, not a path id,
|
|
so it maps to 422.
|
|
- Every OTHER core ``ValueError`` — an empty display name, an explicit
|
|
``null`` colour/avatar_ref (clearing is not supported), a reorder
|
|
list that is not a complete permutation of the children (missing,
|
|
duplicate, or unknown ids); a rejected rule
|
|
(:class:`~nestquest_core.recurrence.RuleValidationError`: unknown
|
|
``rule_type``, a shape-missing field such as a weekly rule without
|
|
weekdays, a malformed date, a forbidden field), a bad window name or
|
|
due time, a rejected window or assignee list (empty, duplicate,
|
|
wrong-typed); a rejected presence pattern or override (a blank name,
|
|
an unknown kind, a bad cycle length, a pattern not covering the
|
|
cycle's weeks, a malformed anchor date, a malformed or
|
|
inverted date range, an override overlapping the same child's
|
|
existing one) — is mapped to 422: the body was well-formed JSON but
|
|
semantically invalid, the same failure class Pydantic reports. The
|
|
core rejects these BEFORE any write, so a 422 never leaves a
|
|
half-applied change behind.
|
|
- A non-existent CHILD on a presence route (listing or creating a
|
|
pattern, or creating an override, for an unknown id) is mapped to 404
|
|
through the SAME :func:`_raise_child_error` the children routes use —
|
|
the message reads ``child N does not exist``. A non-existent
|
|
OVERRIDE on the delete route is mapped to 404 through
|
|
:func:`_raise_presence_override_error`, keyed on the
|
|
``override_id:`` prefix the core's unknown-id error carries (the
|
|
same convention as the quest-definition 404); a non-existent PATTERN
|
|
on the pattern edit/delete routes likewise through
|
|
:func:`_raise_presence_pattern_error`, keyed on ``pattern_id:``.
|
|
- The UNCOMPLETE route maps the core completion layer's unknown-
|
|
``instance_id`` error (prefixed ``instance_id:``, the same
|
|
convention the panel complete route keys on) to 404; every other
|
|
core ``ValueError`` — a rejected actor shape, which a verified token
|
|
without a usable ``sub`` claim produces — is 422: the credential
|
|
authenticated but the reversal it asks for names no subject to
|
|
attribute. Anything ELSE re-raises: no unexpected failure is
|
|
swallowed into a 4xx.
|
|
- The REGENERATE route's only 404s are the unknown scope ids, decided
|
|
by ONE existence read (the definitions/children DAO ``get``) BEFORE
|
|
any core call — the core's regenerate path itself takes no existence
|
|
stance. Every core ``ValueError`` (a rejected horizon, a malformed
|
|
date — unreachable from this body model, but mapped anyway) is 422;
|
|
anything else re-raises.
|
|
- The HISTORY routes have no path ids, so they have no 404 case: a
|
|
missing query parameter (the request's shape) is FastAPI's 422
|
|
before any handler code runs, and every core ``ValueError`` — an
|
|
unknown filter, a non-strict or inverted date range, each naming the
|
|
offending field (``filter`` / ``start`` / ``end``) — is 422
|
|
(:func:`_raise_history_error`): well-formed but semantically
|
|
invalid, the same failure class Pydantic reports. Only
|
|
``ValueError`` is caught, so an unexpected failure re-raises rather
|
|
than becoming a 4xx.
|
|
- The SETTINGS routes have no path ids either, so they have no 404
|
|
case: a body that is not a JSON object is FastAPI's 422 before any
|
|
handler code runs, and every core ``ValueError`` — an unknown field
|
|
or an invalid value, each naming the offending field, raised BEFORE
|
|
any write so the stored settings are never half-changed — is 422
|
|
(:func:`_raise_settings_error`). Only ``ValueError`` is caught, so
|
|
an unexpected failure re-raises rather than becoming a 4xx.
|
|
|
|
PATCH edit semantics: the body model's fields are optional and only
|
|
SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so
|
|
an omitted field is never passed to
|
|
:func:`nestquest_core.children.edit_child` and stays unchanged — while
|
|
an explicit ``null`` IS passed and is rejected by the core rather than
|
|
silently ignored.
|
|
|
|
The EVENTS route (``GET /events``) is the admin plane's live
|
|
transition stream for the Admin PWA: the SAME four transitions
|
|
(quest-completed, quest-uncompleted, quest-missed, child-day-complete)
|
|
off the SAME in-process publisher as the panel plane, framed by the
|
|
shared :func:`api.sse.transition_event_stream` helper. It inherits the
|
|
router's ONE :func:`~api.auth.require_admin` check (the Authorization
|
|
header — the PWA reads it with a fetch-based reader because
|
|
``EventSource`` cannot set headers), so the panel service token is
|
|
refused here exactly as on every other admin route.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
from typing import Annotated, Literal, NoReturn
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import (
|
|
BaseModel,
|
|
StrictBool,
|
|
StrictInt,
|
|
field_validator,
|
|
model_validator,
|
|
)
|
|
|
|
from api import host_zone
|
|
from api import transitions as api_transitions
|
|
from api.auth import require_admin
|
|
from api.database import DatabaseState
|
|
from api.nestquest_core import (
|
|
core_children,
|
|
core_completion,
|
|
core_const,
|
|
core_dao_children,
|
|
core_dao_rules,
|
|
core_history,
|
|
core_icons,
|
|
core_materialize,
|
|
core_presence_management,
|
|
core_quest_definitions,
|
|
core_recurrence,
|
|
core_settings,
|
|
core_settings_store,
|
|
core_snapshot,
|
|
core_sweep,
|
|
)
|
|
from api.sse import transition_event_stream
|
|
|
|
#: All admin-plane routes share this router; the ONE admin dependency
|
|
#: (service-token refusal + JWT verification + nestquest-admins group
|
|
#: gate) is attached HERE so every admin route (current and later)
|
|
#: requires it.
|
|
router = APIRouter(
|
|
prefix="/api/v1/admin",
|
|
dependencies=[Depends(require_admin)],
|
|
)
|
|
|
|
|
|
class AdminPingResponse(BaseModel):
|
|
"""The probe body: the status plus the verified token's subject."""
|
|
|
|
status: str
|
|
#: The JWT's ``sub`` claim — the subject the admin PWA authenticated
|
|
#: as. ``None`` only for a verified token that carries no ``sub``.
|
|
sub: str | None
|
|
|
|
|
|
class AdminChildResponse(BaseModel):
|
|
"""One child profile as the admin plane serializes it.
|
|
|
|
Mirrors :class:`~nestquest_core.dao_children.ChildRecord` minus the
|
|
internal ``created_at`` stamp.
|
|
"""
|
|
|
|
id: int
|
|
display_name: str
|
|
colour: str | None
|
|
avatar_ref: str | None
|
|
sort_order: int
|
|
is_active: bool
|
|
|
|
|
|
class AdminChildListResponse(BaseModel):
|
|
"""The children list: every profile in the household's display order."""
|
|
|
|
children: list[AdminChildResponse]
|
|
|
|
|
|
class AdminRuleResponse(BaseModel):
|
|
"""A definition's schedule rule as the admin plane serializes it.
|
|
|
|
Mirrors :meth:`nestquest_core.recurrence.ScheduleRule.to_dict`: the
|
|
model-name ``rule_type`` (e.g. ``monthly_weekday``), the weekday
|
|
set as a sorted list, and ``None`` for every field the rule's shape
|
|
does not use.
|
|
"""
|
|
|
|
rule_type: str
|
|
interval: int
|
|
weekday_set: list[int] | None
|
|
day_of_month: int | None
|
|
nth_weekday: int | None
|
|
nth_weekday_weekday: int | None
|
|
month: int | None
|
|
start_date: str
|
|
end_date: str | None
|
|
|
|
|
|
class AdminAssigneeResponse(BaseModel):
|
|
"""One assigned child: the id and display name the PWA renders."""
|
|
|
|
id: int
|
|
display_name: str
|
|
|
|
|
|
class AdminDefinitionWindowResponse(BaseModel):
|
|
"""One window declaration: its name and optional HH:MM due time."""
|
|
|
|
window: str
|
|
due_time: str | None
|
|
|
|
|
|
class AdminQuestDefinitionResponse(BaseModel):
|
|
"""One quest definition as the admin plane serializes it.
|
|
|
|
Mirrors the core's
|
|
:class:`~nestquest_core.quest_definitions.CreatedQuestDefinition`
|
|
bundle: the stored row (minus the internal ``schedule_rule_id``/
|
|
``due_time``/``created_at`` columns) plus its decoded rule,
|
|
assignees and windows.
|
|
"""
|
|
|
|
id: int
|
|
title: str
|
|
description: str | None
|
|
icon: str | None
|
|
is_active: bool
|
|
skip_on_away: bool
|
|
rule: AdminRuleResponse
|
|
assignees: list[AdminAssigneeResponse]
|
|
windows: list[AdminDefinitionWindowResponse]
|
|
|
|
|
|
class AdminQuestDefinitionListResponse(BaseModel):
|
|
"""The definitions list: every definition, active AND inactive, by id."""
|
|
|
|
definitions: list[AdminQuestDefinitionResponse]
|
|
|
|
|
|
class AdminQuestDefinitionDeleteResponse(BaseModel):
|
|
"""The body ``DELETE /api/v1/admin/quest-definitions/{id}`` answers.
|
|
|
|
Exactly one of ``deleted`` / ``retired`` is true. ``definition`` is
|
|
the retired (now inactive) definition, or null when it was deleted.
|
|
"""
|
|
|
|
definition_id: int
|
|
deleted: bool
|
|
retired: bool
|
|
definition: AdminQuestDefinitionResponse | None
|
|
|
|
|
|
class AdminChildCreateRequest(BaseModel):
|
|
"""The body of ``POST /api/v1/admin/children``.
|
|
|
|
``display_name`` is required (the core trims it and rejects an
|
|
empty name); the other fields are optional and default through the
|
|
core layer (``colour``/``avatar_ref`` NULL, ``sort_order`` 0).
|
|
"""
|
|
|
|
display_name: str
|
|
colour: str | None = None
|
|
avatar_ref: str | None = None
|
|
sort_order: int = 0
|
|
|
|
|
|
class AdminChildEditRequest(BaseModel):
|
|
"""The body of ``PATCH /api/v1/admin/children/{child_id}``.
|
|
|
|
Every field is optional; ONLY the fields the request supplies are
|
|
forwarded to :func:`nestquest_core.children.edit_child` (the route
|
|
dumps the model with ``exclude_unset``), so an omitted field stays
|
|
unchanged while an explicit ``null`` — a supplied value — reaches
|
|
the core and is rejected there rather than silently ignored.
|
|
"""
|
|
|
|
display_name: str | None = None
|
|
colour: str | None = None
|
|
avatar_ref: str | None = None
|
|
sort_order: int | None = None
|
|
|
|
|
|
class AdminChildActiveRequest(BaseModel):
|
|
"""The body of ``PATCH /api/v1/admin/children/{child_id}/active``."""
|
|
|
|
is_active: bool
|
|
|
|
|
|
class AdminChildReorderRequest(BaseModel):
|
|
"""The body of ``POST /api/v1/admin/children/reorder``.
|
|
|
|
``ordered_ids`` must be a complete permutation of the household's
|
|
child ids (the core rejects a partial, duplicate, or unknown list
|
|
before any write).
|
|
"""
|
|
|
|
ordered_ids: list[int]
|
|
|
|
|
|
class AdminRuleRequest(BaseModel):
|
|
"""The ``rule`` object of a quest-definition create or edit body.
|
|
|
|
The fields of :class:`~nestquest_core.recurrence.ScheduleRule` —
|
|
nothing more: the shape is the model's, the defaults are the
|
|
model's (``interval`` 1, ``start_date`` 1970-01-01). Only
|
|
``rule_type`` is required. The COMBINATION policy (which fields a
|
|
shape requires and forbids, the 1..31/1..12/0..6 ranges, the strict
|
|
ISO dates) is enforced by the model's constructor, which the route
|
|
builds through ``from_dict`` — this model only guards the SHAPE.
|
|
"""
|
|
|
|
rule_type: str
|
|
interval: int = 1
|
|
weekday_set: list[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
|
|
|
|
|
|
#: Upper bound on ``count`` for the occurrence-preview route.
|
|
_PREVIEW_MAX_COUNT = 50
|
|
|
|
#: Days after ``start_date`` the occurrence-preview window spans
|
|
#: (inclusive), so a yearly rule always shows its next occurrence.
|
|
_PREVIEW_WINDOW_DAYS = 366
|
|
|
|
|
|
class AdminOccurrencesPreviewRequest(BaseModel):
|
|
"""The body of ``POST /api/v1/admin/quest-definitions/occurrences-preview``.
|
|
|
|
``rule`` is the SAME object create/edit take. ``start_date`` is an
|
|
optional strict ``YYYY-MM-DD`` (validated by the core; default the
|
|
household-local today in the configured ``timezone``, empty falling
|
|
back to the API host's local time). ``count`` is a strict JSON
|
|
integer in 1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before
|
|
any handler code runs.
|
|
"""
|
|
|
|
rule: AdminRuleRequest
|
|
start_date: str | None = None
|
|
count: StrictInt = 10
|
|
|
|
@field_validator("count")
|
|
@classmethod
|
|
def _count_in_range(cls, value: int) -> int:
|
|
"""Reject a count outside 1.._PREVIEW_MAX_COUNT."""
|
|
if not 1 <= value <= _PREVIEW_MAX_COUNT:
|
|
raise ValueError(
|
|
f"count must be between 1 and {_PREVIEW_MAX_COUNT}"
|
|
)
|
|
return value
|
|
|
|
|
|
class AdminOccurrencesPreviewResponse(BaseModel):
|
|
"""The next occurrence dates (``YYYY-MM-DD``, ascending)."""
|
|
|
|
dates: list[str]
|
|
|
|
|
|
class AdminQuestDefinitionCreateRequest(BaseModel):
|
|
"""The body of ``POST /api/v1/admin/quest-definitions``.
|
|
|
|
``title``, ``rule``, ``assignee_child_ids`` and ``windows`` are
|
|
required (the core rejects an empty title, a non-list or empty
|
|
assignee/window list, and unknown window names); ``description``
|
|
and ``icon`` are optional and default to NULL through the core, and
|
|
``skip_on_away`` is optional and defaults to true (no instance on a
|
|
date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item
|
|
``[window, due_time]`` pair whose due time is a strict 24-hour
|
|
HH:MM (or ``null`` for none) — both forms the core normalizes.
|
|
Assignee ids are strict JSON integers: a boolean, float or numeric
|
|
string is 422, never coerced to a child id.
|
|
"""
|
|
|
|
title: str
|
|
rule: AdminRuleRequest
|
|
assignee_child_ids: list[StrictInt]
|
|
windows: list[str | tuple[str, str | None]]
|
|
description: str | None = None
|
|
icon: str | None = None
|
|
skip_on_away: StrictBool = True
|
|
|
|
|
|
class AdminQuestDefinitionEditRequest(BaseModel):
|
|
"""The body of ``PATCH /api/v1/admin/quest-definitions/{definition_id}``.
|
|
|
|
Every field is optional; ONLY the fields the request supplies are
|
|
forwarded to :func:`nestquest_core.quest_definitions.edit_quest_definition`
|
|
(the route dumps the model with ``exclude_unset``), so an omitted
|
|
field stays unchanged. An explicit ``null`` ``description``/
|
|
``icon`` IS passed and CLEARS the stored value (the core supports
|
|
clearing here, unlike the children edit); an explicit ``null``
|
|
``rule``, ``windows`` or ``assignee_child_ids`` is passed and
|
|
rejected by the core. A supplied ``windows`` list REPLACES the
|
|
whole window set, and a supplied ``assignee_child_ids`` list
|
|
REPLACES the whole assignee set (non-empty, every id an existing
|
|
active child — create's contract; a rejection changes nothing). An
|
|
omitted (or ``null``) ``skip_on_away`` keeps the stored value.
|
|
Assignee ids are strict JSON integers, as on create.
|
|
"""
|
|
|
|
title: str | None = None
|
|
description: str | None = None
|
|
icon: str | None = None
|
|
rule: AdminRuleRequest | None = None
|
|
windows: list[str | tuple[str, str | None]] | None = None
|
|
skip_on_away: StrictBool | None = None
|
|
assignee_child_ids: list[StrictInt] | None = None
|
|
|
|
|
|
class AdminQuestDefinitionActiveRequest(BaseModel):
|
|
"""The body of ``PATCH /api/v1/admin/quest-definitions/{id}/active``."""
|
|
|
|
is_active: bool
|
|
|
|
|
|
class AdminPresencePatternCreateRequest(BaseModel):
|
|
"""The body of ``POST /api/v1/admin/children/{child_id}/presence-patterns``.
|
|
|
|
The shape of :class:`~nestquest_core.presence.PresencePattern` —
|
|
nothing more. ``pattern`` maps each cycle week index (JSON object
|
|
keys, coerced to ints by the model) to the weekdays the pattern
|
|
covers that week (Monday=0); every week of the cycle must be
|
|
covered, which the PRESENCE MODEL enforces at construction, not
|
|
this shape — as it does the non-blank ``name`` and the
|
|
``home``/``away`` ``kind``. ``cycle_length_weeks`` and the weekday
|
|
values are strict JSON integers: ``2.0``, ``"2"`` or ``true`` is a
|
|
422, not a silent coercion.
|
|
"""
|
|
|
|
name: str
|
|
kind: str
|
|
cycle_length_weeks: StrictInt
|
|
anchor_date: str
|
|
pattern: dict[int, list[StrictInt]]
|
|
|
|
|
|
class AdminPresencePatternEditRequest(BaseModel):
|
|
"""The body of ``PATCH /api/v1/admin/presence-patterns/{pattern_id}``.
|
|
|
|
Every field optional; only SUPPLIED fields are forwarded, and an
|
|
explicit ``null`` is forwarded too — and rejected by the model.
|
|
The pattern's child cannot be changed.
|
|
"""
|
|
|
|
name: str | None = None
|
|
kind: str | None = None
|
|
cycle_length_weeks: StrictInt | None = None
|
|
anchor_date: str | None = None
|
|
pattern: dict[int, list[StrictInt]] | None = None
|
|
|
|
|
|
class AdminPresencePatternResponse(BaseModel):
|
|
"""One presence pattern as the admin plane serializes it.
|
|
|
|
The stored row with its weekday sets decoded back through
|
|
:meth:`nestquest_core.presence.PresencePattern.decode`: each
|
|
pattern week is the sorted list of covered weekdays. The ``id`` is
|
|
the handle the PATCH and DELETE routes take.
|
|
"""
|
|
|
|
id: int
|
|
child_id: int
|
|
name: str
|
|
kind: str
|
|
cycle_length_weeks: int
|
|
anchor_date: str
|
|
pattern: dict[int, list[int]]
|
|
|
|
|
|
class AdminPresencePatternListResponse(BaseModel):
|
|
"""The ``GET /children/{child_id}/presence-patterns`` payload.
|
|
|
|
``patterns`` is empty when the child has none — the child is
|
|
present every day (Feature 05); absence is state, not an error.
|
|
"""
|
|
|
|
patterns: list[AdminPresencePatternResponse]
|
|
|
|
|
|
class AdminPresenceOverrideCreateRequest(BaseModel):
|
|
"""The body of ``POST /api/v1/admin/presence-overrides``.
|
|
|
|
The shape of :class:`~nestquest_core.presence.PresenceOverride` —
|
|
the strict ISO dates, the ``end_date >= start_date`` order, the
|
|
real ``is_present`` bool and the optional note are ALL enforced by
|
|
the model's constructor, which the route builds directly from this
|
|
body; this model only guards the SHAPE.
|
|
"""
|
|
|
|
child_id: int
|
|
start_date: str
|
|
end_date: str
|
|
is_present: bool
|
|
note: str | None = None
|
|
|
|
|
|
class AdminPresenceOverrideResponse(BaseModel):
|
|
"""One presence override as the admin plane serializes it.
|
|
|
|
Mirrors
|
|
:class:`~nestquest_core.dao_presence.PresenceOverrideRecord` — the
|
|
``id`` is the handle the DELETE route takes.
|
|
"""
|
|
|
|
id: int
|
|
child_id: int
|
|
start_date: str
|
|
end_date: str
|
|
is_present: bool
|
|
note: str | None
|
|
|
|
|
|
class AdminPresenceOverrideListResponse(BaseModel):
|
|
"""The ``GET /presence-overrides`` payload: the matching overrides."""
|
|
|
|
overrides: list[AdminPresenceOverrideResponse]
|
|
|
|
|
|
class AdminPresenceOverrideConsequenceRequest(BaseModel):
|
|
"""The body of ``POST /api/v1/admin/presence-overrides/consequence``.
|
|
|
|
The create body's shape minus the note (which cannot change what is
|
|
generated): the strict dates, their order and the real
|
|
``is_present`` bool are enforced by the SAME
|
|
:class:`~nestquest_core.presence.PresenceOverride` constructor the
|
|
create route's core path builds; this model only guards the SHAPE.
|
|
Strict fields: a JSON ``true`` is not child id 1, nor ``1`` a bool.
|
|
"""
|
|
|
|
child_id: StrictInt
|
|
start_date: str
|
|
end_date: str
|
|
is_present: StrictBool
|
|
|
|
|
|
class AdminPresenceOverrideConsequenceResponse(BaseModel):
|
|
"""The consequence preview: ``removed`` upcoming instances.
|
|
|
|
The number of open (no completion event) upcoming quest instances
|
|
of the child that saving the override would remove — the "This
|
|
removes N upcoming tasks" warning. Always 0 for a present override.
|
|
"""
|
|
|
|
removed: int
|
|
|
|
|
|
class AdminUncompleteResponse(BaseModel):
|
|
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
|
|
|
|
``state`` is the instance's derived state AFTER the reversal —
|
|
``open``, or ``missed`` when it is past due — and ``appended``
|
|
reports whether THIS call appended the reversal event (``False``
|
|
for an already-open no-op).
|
|
"""
|
|
|
|
instance_id: int
|
|
state: str
|
|
appended: bool
|
|
|
|
|
|
class AdminRegenerateRequest(BaseModel):
|
|
"""The body of ``POST /api/v1/admin/regenerate``.
|
|
|
|
ONE scope per request: ``household`` (the default) re-materializes
|
|
every active definition over the rolling horizon; ``definition``
|
|
and ``child`` scope the rebuild to one id. The model enforces the
|
|
scope/id pairing so the handler never has to guess: an id without
|
|
its scope, a scope without its id, or a zero/negative id is a
|
|
validation error (422) before any handler code runs.
|
|
"""
|
|
|
|
scope: Literal["household", "definition", "child"] = "household"
|
|
definition_id: int | None = None
|
|
child_id: int | None = None
|
|
|
|
@field_validator("definition_id", "child_id")
|
|
@classmethod
|
|
def _positive(cls, value: int | None) -> int | None:
|
|
"""Reject zero and negative ids with a validation error."""
|
|
if value is not None and value < 1:
|
|
raise ValueError(
|
|
"definition_id and child_id must be positive integers"
|
|
)
|
|
return value
|
|
|
|
@model_validator(mode="after")
|
|
def _scope_matches_ids(self) -> "AdminRegenerateRequest":
|
|
"""Reject an id that does not belong to the requested scope."""
|
|
if self.scope == "household":
|
|
if self.definition_id is not None or self.child_id is not None:
|
|
raise ValueError(
|
|
"scope 'household' takes no definition_id or child_id"
|
|
)
|
|
elif self.scope == "definition":
|
|
if self.definition_id is None:
|
|
raise ValueError("scope 'definition' requires definition_id")
|
|
if self.child_id is not None:
|
|
raise ValueError("scope 'definition' must not carry child_id")
|
|
else:
|
|
if self.child_id is None:
|
|
raise ValueError("scope 'child' requires child_id")
|
|
if self.definition_id is not None:
|
|
raise ValueError(
|
|
"scope 'child' must not carry definition_id"
|
|
)
|
|
return self
|
|
|
|
|
|
class AdminRegenerateResponse(BaseModel):
|
|
"""What a regenerate request rebuilt.
|
|
|
|
``scope`` echoes the request; ``definition_id``/``child_id`` carry
|
|
the scoped id (both ``None`` for the household); ``count`` is the
|
|
number of instances the core walk upserted — the core's idempotent
|
|
count, so a re-run refreshes rows in place and never duplicates.
|
|
"""
|
|
|
|
scope: str
|
|
definition_id: int | None
|
|
child_id: int | None
|
|
count: int
|
|
|
|
|
|
class AdminHistoryRow(BaseModel):
|
|
"""One history row as the admin plane serializes it.
|
|
|
|
Mirrors :class:`nestquest_core.history.HistoryRow` — the Admin spec
|
|
§5 event-row fields, the same set the CSV export's header carries.
|
|
A stored event carries its UTC ``occurred_at`` stamp and the
|
|
``completed``/``uncompleted`` ``event_type``; a derived missed row
|
|
(``missed`` is NEVER a stored event_type) carries ``event_type``
|
|
``'missed'``, ``occurred_at`` ``None`` (there is no event) and the
|
|
``nightly sweep`` actor.
|
|
"""
|
|
|
|
occurred_at: str | None
|
|
event_type: str
|
|
child_name: str
|
|
child_id: int
|
|
quest_title: str
|
|
instance_id: int
|
|
window: str
|
|
due_date: str
|
|
due_time: str | None
|
|
actor: str
|
|
was_on_time: bool | None
|
|
|
|
|
|
class AdminHistoryResponse(BaseModel):
|
|
"""The history query's answer.
|
|
|
|
``filter``/``start``/``end`` echo the request, ``count`` is the row
|
|
count, and ``rows`` are the core's rows in its deterministic order
|
|
(``due_date``, then instance, then event id — the per-instance
|
|
append order, so a completion and its reversal keep their order).
|
|
"""
|
|
|
|
filter: str
|
|
start: str
|
|
end: str
|
|
count: int
|
|
rows: list[AdminHistoryRow]
|
|
|
|
|
|
class AdminSettingsResponse(BaseModel):
|
|
"""The effective settings as the admin plane serializes them.
|
|
|
|
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
|
|
— all ELEVEN documented fields, resolved (every absent stored field
|
|
already fell back to its default in the core). The same shape both
|
|
the GET and the PATCH route answer with.
|
|
"""
|
|
|
|
horizon_days: int
|
|
day_rollover_time: str
|
|
notify_target: str
|
|
morning_summary_time: str
|
|
afternoon_reminder_time: str
|
|
end_of_day_report_time: str
|
|
morning_summary_enabled: bool
|
|
afternoon_reminder_enabled: bool
|
|
end_of_day_report_enabled: bool
|
|
celebration_enabled: bool
|
|
#: The household IANA time zone the admin stored; ``""`` means the
|
|
#: API host's local time.
|
|
timezone: str
|
|
#: The zone the API actually reads its clock in — ``timezone``, or
|
|
#: the API host's own zone when that is ``""`` (``""`` only when the
|
|
#: host's zone cannot be named). Read-only; the admin app formats
|
|
#: local time in it.
|
|
effective_timezone: str
|
|
|
|
|
|
class AdminMissedSweepResponse(BaseModel):
|
|
"""The result of ``POST /api/v1/admin/missed-sweep``.
|
|
|
|
``fired`` is the number of ``nestquest_quest_missed`` transitions
|
|
the run built AND this route published on the SSE stream — zero
|
|
when the sweep's watermark already covers today (a same-day rerun).
|
|
"""
|
|
|
|
fired: int
|
|
|
|
|
|
class AdminInstancePayload(BaseModel):
|
|
"""One instance in the admin snapshot payload.
|
|
|
|
Mirrors :func:`nestquest_core.snapshot.instance_payload` with
|
|
``include_missed=True``: ``state`` is ``open`` | ``completed`` |
|
|
``missed`` (admin payloads keep missed instances, D-009),
|
|
``on_time`` passes the completion event's flag through verbatim
|
|
(``None`` unless completed), and ``completed_at`` is the event's
|
|
UTC timestamp when completed.
|
|
"""
|
|
|
|
id: int
|
|
definition_id: int
|
|
child_id: int
|
|
title: str
|
|
icon: str | None
|
|
window: str
|
|
due_time: str | None
|
|
state: str
|
|
overdue: bool
|
|
completed_at: str | None
|
|
on_time: bool | None
|
|
|
|
|
|
class AdminChildSnapshotPayload(BaseModel):
|
|
"""One child's day: presence, rollup counts, and today's instances."""
|
|
|
|
child_id: int
|
|
child_name: str
|
|
present: bool
|
|
#: ISO date of the child's next present day while they are away;
|
|
#: ``None`` when the child is present today.
|
|
next_present: str | None
|
|
due_today: int
|
|
completed_today: int
|
|
remaining_today: int
|
|
completion_pct: int
|
|
instances: list[AdminInstancePayload]
|
|
|
|
|
|
class AdminSnapshotResponse(BaseModel):
|
|
"""Today's whole household snapshot for the admin Today tab.
|
|
|
|
The panel snapshot's shape (``today_iso``, ``cycle_day``,
|
|
``children`` in the household's sort order), with missed instances
|
|
kept in each child's ``instances``.
|
|
"""
|
|
|
|
today_iso: str
|
|
cycle_day: int
|
|
children: list[AdminChildSnapshotPayload]
|
|
|
|
|
|
#: 404 detail for a child id the core layer reports as non-existent.
|
|
_CHILD_NOT_FOUND_DETAIL = "Child not found"
|
|
|
|
|
|
def _raise_child_error(error: ValueError) -> NoReturn:
|
|
"""Map a core children ``ValueError`` onto 404 or 422 and raise it.
|
|
|
|
The core layer raises ``ValueError`` for two distinct situations,
|
|
and this mapping is the children routes' one piece of adapter
|
|
logic: a non-existent child on a single-id route (edit, set_active
|
|
— the message reads ``child N does not exist``) becomes 404, the
|
|
admin plane's not-found answer; every other core ``ValueError``
|
|
becomes 422 with the core's message as the detail — it names the
|
|
field and what to fix, and leaks nothing but that validation rule.
|
|
"""
|
|
if "does not exist" in str(error):
|
|
raise HTTPException(
|
|
status_code=404, detail=_CHILD_NOT_FOUND_DETAIL
|
|
) from error
|
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
|
|
|
|
def _child_response(record: core_children.ChildRecord) -> AdminChildResponse:
|
|
"""Serialize one core ChildRecord into the documented payload."""
|
|
return AdminChildResponse(
|
|
id=record.id,
|
|
display_name=record.display_name,
|
|
colour=record.colour,
|
|
avatar_ref=record.avatar_ref,
|
|
sort_order=record.sort_order,
|
|
is_active=record.is_active,
|
|
)
|
|
|
|
|
|
#: 404 detail for a definition id the core layer reports as non-existent.
|
|
_QUEST_DEFINITION_NOT_FOUND_DETAIL = "Quest definition not found"
|
|
|
|
|
|
def _raise_quest_definition_error(error: ValueError) -> NoReturn:
|
|
"""Map a core quest-definitions ``ValueError`` onto 404 or 422.
|
|
|
|
The mapping mirrors :func:`_raise_child_error` and stays narrow:
|
|
the core names a missing definition by prefixing its error with
|
|
``definition_id:`` (edit or set_active on an unknown id — its id
|
|
validator's message reads ``definition_id must be an integer``
|
|
WITHOUT the colon, so a malformed id stays 422), and that one case
|
|
is the quest-definition routes' 404. Every other ``ValueError`` —
|
|
including :class:`~nestquest_core.recurrence.RuleValidationError`,
|
|
which subclasses it — becomes 422 with the core's message as the
|
|
detail: create's rejected assignee names a child from the BODY (not
|
|
a path id, so not 404), a rejected rule/window/title names the
|
|
field and what to fix, and none of it leaks more than the rule it
|
|
enforces.
|
|
"""
|
|
if str(error).startswith("definition_id:"):
|
|
raise HTTPException(
|
|
status_code=404, detail=_QUEST_DEFINITION_NOT_FOUND_DETAIL
|
|
) from error
|
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
|
|
|
|
#: 404 detail for an override id the core layer reports as non-existent.
|
|
_PRESENCE_OVERRIDE_NOT_FOUND_DETAIL = "Presence override not found"
|
|
|
|
|
|
def _raise_presence_override_error(error: ValueError) -> NoReturn:
|
|
"""Map a core presence ``ValueError`` from the DELETE route onto 404/422.
|
|
|
|
The mapping mirrors :func:`_raise_quest_definition_error`: the core
|
|
names a missing override by prefixing its error with
|
|
``override_id:`` (an unknown id), and that one case is this route's
|
|
404. Every other ``ValueError`` — a non-integer id the path
|
|
parser's ``int`` already makes unreachable — becomes 422. The
|
|
pattern list/create and override-CREATE routes do NOT use this
|
|
mapper: their only 404 is an unknown CHILD, mapped by
|
|
:func:`_raise_child_error`.
|
|
"""
|
|
if str(error).startswith("override_id:"):
|
|
raise HTTPException(
|
|
status_code=404, detail=_PRESENCE_OVERRIDE_NOT_FOUND_DETAIL
|
|
) from error
|
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
|
|
|
|
#: 404 detail for a pattern id the core layer reports as non-existent.
|
|
_PRESENCE_PATTERN_NOT_FOUND_DETAIL = "Presence pattern not found"
|
|
|
|
|
|
def _raise_presence_pattern_error(error: ValueError) -> NoReturn:
|
|
"""Map a core ``ValueError`` from the pattern PATCH/DELETE routes.
|
|
|
|
Mirrors :func:`_raise_presence_override_error`: the core names a
|
|
missing pattern by prefixing its error with ``pattern_id:``, and
|
|
that one case is 404. Every other ``ValueError`` — a rejected edit
|
|
(blank name, unknown kind, bad cycle length, malformed anchor date
|
|
or pattern), each naming its field — becomes 422.
|
|
"""
|
|
if str(error).startswith("pattern_id:"):
|
|
raise HTTPException(
|
|
status_code=404, detail=_PRESENCE_PATTERN_NOT_FOUND_DETAIL
|
|
) from error
|
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
|
|
|
|
#: 404 detail for an instance id the core layer reports as non-existent.
|
|
_QUEST_INSTANCE_NOT_FOUND_DETAIL = "Quest instance not found"
|
|
|
|
|
|
def _raise_instance_error(error: ValueError) -> NoReturn:
|
|
"""Map a core completion ``ValueError`` onto 404 or 422 and raise it.
|
|
|
|
The uncomplete route's mapping, keyed the same way the panel
|
|
complete route keys its 404: the core names a missing instance by
|
|
prefixing its error with ``instance_id:``
|
|
(:func:`nestquest_core.completion.uncomplete_instance` on an
|
|
unknown id), and that one case is this route's 404. Every other
|
|
``ValueError`` — a rejected actor shape, which a verified token
|
|
without a usable ``sub`` claim produces — is 422 with the core's
|
|
message as the detail. The route catches ONLY ``ValueError``, so
|
|
an unexpected failure propagates untouched.
|
|
"""
|
|
if str(error).startswith("instance_id:"):
|
|
raise HTTPException(
|
|
status_code=404, detail=_QUEST_INSTANCE_NOT_FOUND_DETAIL
|
|
) from error
|
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
|
|
|
|
def _raise_regenerate_error(error: ValueError) -> NoReturn:
|
|
"""Map a core materialization ``ValueError`` onto 422 and raise it.
|
|
|
|
The regenerate route's 404s are decided BEFORE the core call (the
|
|
handler's own existence reads of the scoped id), so every
|
|
``ValueError`` that survives to here is a rejected argument — a
|
|
malformed horizon or date the core refuses before any write — and
|
|
is 422 with the core's message as the detail. Nothing else is
|
|
caught: an unexpected failure re-raises rather than becoming a 4xx.
|
|
"""
|
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
|
|
|
|
def _raise_history_error(error: ValueError) -> NoReturn:
|
|
"""Map a core history ``ValueError`` onto 422 and raise it.
|
|
|
|
The history routes have no path ids, so they have no 404 case:
|
|
every ``ValueError`` that survives to here is a rejected argument —
|
|
an unknown filter, a non-strict date, or an inverted range, each
|
|
raised by the core naming the offending field (``filter`` /
|
|
``start`` / ``end``) BEFORE any read — and is 422 with the core's
|
|
message as the detail. Only ``ValueError`` is caught by the
|
|
handlers, so an unexpected failure re-raises rather than becoming a
|
|
4xx.
|
|
"""
|
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
|
|
|
|
def _raise_settings_error(error: ValueError) -> NoReturn:
|
|
"""Map a core settings-store ``ValueError`` onto 422 and raise it.
|
|
|
|
The settings routes have no path ids either, so they have no 404
|
|
case: every ``ValueError`` that survives to here is a rejected
|
|
change — an unknown field or an invalid value, each raised by the
|
|
core naming the offending field BEFORE any write, so the stored
|
|
settings are never half-changed — and is 422 with the core's
|
|
message as the detail. Only ``ValueError`` is caught, so an
|
|
unexpected failure re-raises rather than becoming a 4xx.
|
|
"""
|
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
|
|
|
|
def _raise_sweep_error(error: ValueError) -> NoReturn:
|
|
"""Map a core missed-sweep ``ValueError`` onto 422 and raise it.
|
|
|
|
The sweep route has no path ids, so it has no 404 case: a core
|
|
``ValueError`` — a rejected date the sweep's query refuses before
|
|
any read, unreachable from this handler because ``today`` comes
|
|
from the route's own clock read — is 422 with the core's message
|
|
as the detail. Only ``ValueError`` is caught, so an unexpected
|
|
failure re-raises rather than becoming a 4xx.
|
|
"""
|
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
|
|
|
|
def _raise_preview_error(error: ValueError) -> NoReturn:
|
|
"""Map a core recurrence ``ValueError`` from the preview route to 422.
|
|
|
|
The preview route has no path ids, so it has no 404 case: a
|
|
rejected rule (:class:`~nestquest_core.recurrence.RuleValidationError`)
|
|
or a non-strict ``start_date`` is 422 with the core's message as
|
|
the detail. Only ``ValueError`` is caught, so an unexpected
|
|
failure re-raises rather than becoming a 4xx.
|
|
"""
|
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
|
|
|
|
def _preview_window_end(start_date: str) -> str:
|
|
"""Return the preview window's inclusive end for ``start_date``.
|
|
|
|
``start_date`` + :data:`_PREVIEW_WINDOW_DAYS`. Parsing here only
|
|
locates the window; the STRICT ``YYYY-MM-DD`` check stays the
|
|
core's — :func:`~nestquest_core.recurrence.occurrences_between`
|
|
re-validates the same string and rejects any lenient form this
|
|
parse accepted. Raises ``ValueError`` for an unparseable value and
|
|
for a start whose window would reach the calendar's last date (the
|
|
core's day walk steps one day PAST its end, which overflows there).
|
|
"""
|
|
start = datetime.date.fromisoformat(start_date)
|
|
if datetime.date.max - start <= datetime.timedelta(
|
|
days=_PREVIEW_WINDOW_DAYS
|
|
):
|
|
raise ValueError(
|
|
f"start_date {start_date!r} is too close to the end of the "
|
|
"calendar for a preview window"
|
|
)
|
|
return (start + datetime.timedelta(days=_PREVIEW_WINDOW_DAYS)).isoformat()
|
|
|
|
|
|
def _local_now() -> datetime.datetime:
|
|
"""Return the API host's local time as ONE timezone-aware clock read.
|
|
|
|
Same discipline as the panel route's clock read: a single
|
|
``now().astimezone()`` read anchors the household-local date the
|
|
regenerate horizon and the uncomplete stamp derive from, so the
|
|
calendar date can never disagree inside one request. Kept as a
|
|
module function so tests pin the instant the same way the panel
|
|
route's ``_local_now`` is pinned.
|
|
"""
|
|
return datetime.datetime.now().astimezone()
|
|
|
|
|
|
async def _household_clock(
|
|
database: object,
|
|
) -> tuple[object, datetime.datetime]:
|
|
"""Return the stored settings and ONE household-local clock read.
|
|
|
|
The API host may run UTC while the household does not, so the one
|
|
:func:`_local_now` read is re-expressed in the stored settings'
|
|
``timezone`` (:meth:`NestQuestSettings.household_now`); an unset
|
|
zone keeps the host's local time.
|
|
"""
|
|
settings = await core_settings_store.load_settings(database)
|
|
return settings, settings.household_now(_local_now())
|
|
|
|
|
|
async def _household_today(database: object) -> datetime.date:
|
|
"""Return the household-local date of ONE :func:`_household_clock` read."""
|
|
_settings, now = await _household_clock(database)
|
|
return now.date()
|
|
|
|
|
|
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
|
|
"""Build the core ScheduleRule from the request's ``rule`` object.
|
|
|
|
The recurrence model validates AT CONSTRUCTION — through its own
|
|
``from_dict`` here, which raises
|
|
:class:`~nestquest_core.recurrence.RuleValidationError` (a
|
|
``ValueError``) for any bad combination — so the route re-implements
|
|
no rule policy of its own.
|
|
"""
|
|
return core_recurrence.ScheduleRule.from_dict(
|
|
rule.model_dump(exclude_unset=True)
|
|
)
|
|
|
|
|
|
def _rule_response(rule: core_recurrence.ScheduleRule) -> AdminRuleResponse:
|
|
"""Serialize one core ScheduleRule via its lossless dict form."""
|
|
return AdminRuleResponse(**rule.to_dict())
|
|
|
|
|
|
def _quest_definition_response(
|
|
bundle: core_quest_definitions.CreatedQuestDefinition,
|
|
) -> AdminQuestDefinitionResponse:
|
|
"""Serialize one core CreatedQuestDefinition into the payload."""
|
|
return AdminQuestDefinitionResponse(
|
|
id=bundle.definition.id,
|
|
title=bundle.definition.title,
|
|
description=bundle.definition.description,
|
|
icon=core_icons.normalize_icon_for_read(bundle.definition.icon),
|
|
is_active=bundle.definition.is_active,
|
|
skip_on_away=bundle.definition.skip_on_away,
|
|
rule=_rule_response(bundle.rule),
|
|
assignees=[
|
|
AdminAssigneeResponse(id=child.id, display_name=child.display_name)
|
|
for child in bundle.assignees
|
|
],
|
|
windows=[
|
|
AdminDefinitionWindowResponse(
|
|
window=window.window, due_time=window.due_time
|
|
)
|
|
for window in bundle.windows
|
|
],
|
|
)
|
|
|
|
|
|
def _presence_pattern_response(
|
|
record: core_presence_management.PresencePatternRecord,
|
|
) -> AdminPresencePatternResponse:
|
|
"""Serialize one stored core pattern, weekday sets decoded."""
|
|
pattern = core_presence_management.decode_pattern_record(record)
|
|
return AdminPresencePatternResponse(
|
|
id=record.id,
|
|
child_id=pattern.child_id,
|
|
name=pattern.name,
|
|
kind=pattern.kind,
|
|
cycle_length_weeks=pattern.cycle_length_weeks,
|
|
anchor_date=pattern.anchor_date.isoformat(),
|
|
pattern={
|
|
week: sorted(days) for week, days in pattern.pattern.items()
|
|
},
|
|
)
|
|
|
|
|
|
def _presence_override_response(
|
|
record: core_presence_management.PresenceOverrideRecord,
|
|
) -> AdminPresenceOverrideResponse:
|
|
"""Serialize one core PresenceOverrideRecord into the payload."""
|
|
return AdminPresenceOverrideResponse(
|
|
id=record.id,
|
|
child_id=record.child_id,
|
|
start_date=record.start_date,
|
|
end_date=record.end_date,
|
|
is_present=record.is_present,
|
|
note=record.note,
|
|
)
|
|
|
|
|
|
@router.get("/ping", summary="Admin plane probe")
|
|
async def admin_ping(
|
|
claims: Annotated[dict[str, object], Depends(require_admin)],
|
|
) -> AdminPingResponse:
|
|
"""Return 200 for a verified JWT naming the nestquest-admins group.
|
|
|
|
Requires a valid Authentik JWT with the admin group (checked by the
|
|
router's shared :func:`~api.auth.require_admin` dependency — the
|
|
ONE check; this handler performs NO auth of its own). ``claims``
|
|
is the SAME verified-claims dict that dependency produced (FastAPI's
|
|
per-request dependency cache), so the token is verified exactly
|
|
once per request.
|
|
"""
|
|
sub = claims.get("sub")
|
|
return AdminPingResponse(
|
|
status="ok",
|
|
sub=sub if isinstance(sub, str) else None,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/children",
|
|
summary="List child profiles",
|
|
response_model=AdminChildListResponse,
|
|
)
|
|
async def admin_list_children(request: Request) -> AdminChildListResponse:
|
|
"""Return every child profile in display order.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: one
|
|
:func:`nestquest_core.children.list_children` call, active AND
|
|
inactive children included (the admin screen manages both), in the
|
|
core's display order (``sort_order``, then ``id``).
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
records = await core_children.list_children(state.database)
|
|
return AdminChildListResponse(
|
|
children=[_child_response(record) for record in records]
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/children",
|
|
summary="Create a child profile",
|
|
status_code=201,
|
|
response_model=AdminChildResponse,
|
|
)
|
|
async def admin_create_child(
|
|
body: AdminChildCreateRequest, request: Request
|
|
) -> AdminChildResponse:
|
|
"""Create a child profile and return it as stored.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the body model
|
|
carries the shape, and the name policy (trim, reject empty), the
|
|
duplicate-name warning and the created_at stamp all live in
|
|
:func:`nestquest_core.children.create_child`. Errors are mapped by
|
|
:func:`_raise_child_error` — create names no existing child, so a
|
|
rejection here is always 422.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
record = await core_children.create_child(
|
|
state.database,
|
|
body.display_name,
|
|
colour=body.colour,
|
|
avatar_ref=body.avatar_ref,
|
|
sort_order=body.sort_order,
|
|
)
|
|
except ValueError as error:
|
|
_raise_child_error(error)
|
|
return _child_response(record)
|
|
|
|
|
|
@router.patch(
|
|
"/children/{child_id}",
|
|
summary="Edit a child profile",
|
|
response_model=AdminChildResponse,
|
|
)
|
|
async def admin_edit_child(
|
|
child_id: int, body: AdminChildEditRequest, request: Request
|
|
) -> AdminChildResponse:
|
|
"""Edit a child profile; only supplied fields change.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the supplied
|
|
fields are forwarded with ``exclude_unset`` semantics so an omitted
|
|
field is never passed to :func:`nestquest_core.children.edit_child`
|
|
and stays unchanged, while an explicit ``null`` is passed and
|
|
rejected by the core (clearing is not supported). An unknown child
|
|
id is mapped to 404 and any other core ``ValueError`` to 422
|
|
(:func:`_raise_child_error`).
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
record = await core_children.edit_child(
|
|
state.database,
|
|
child_id,
|
|
**body.model_dump(exclude_unset=True),
|
|
)
|
|
except ValueError as error:
|
|
_raise_child_error(error)
|
|
return _child_response(record)
|
|
|
|
|
|
@router.patch(
|
|
"/children/{child_id}/active",
|
|
summary="Set a child's active flag",
|
|
response_model=AdminChildResponse,
|
|
)
|
|
async def admin_set_child_active(
|
|
child_id: int, body: AdminChildActiveRequest, request: Request
|
|
) -> AdminChildResponse:
|
|
"""Deactivate (or reactivate) a child; the profile is NEVER deleted.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: ``is_active``
|
|
is passed straight to
|
|
:func:`nestquest_core.children.set_child_active` — deactivation is
|
|
the only removal path (the core has no delete, so completion
|
|
history keeps its references). An unknown child id is mapped to
|
|
404 (:func:`_raise_child_error`); the body model already makes the
|
|
core's real-bool check unreachable from this route.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
record = await core_children.set_child_active(
|
|
state.database, child_id, body.is_active
|
|
)
|
|
except ValueError as error:
|
|
_raise_child_error(error)
|
|
return _child_response(record)
|
|
|
|
|
|
@router.post(
|
|
"/children/reorder",
|
|
summary="Reorder the children's panel display order",
|
|
)
|
|
async def admin_reorder_children(
|
|
body: AdminChildReorderRequest, request: Request
|
|
) -> dict[str, str]:
|
|
"""Rewrite the children's display order in one atomic core call.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the posted id
|
|
order goes straight to
|
|
:func:`nestquest_core.children.reorder_children`, which rejects a
|
|
list that is not a complete permutation of the household (missing,
|
|
duplicate, or unknown ids) BEFORE any write — mapped to 422 by
|
|
:func:`_raise_child_error`, leaving the stored order untouched. On
|
|
success the core returns ``None``; the route answers
|
|
``{"status": "ok"}`` (the fresh order is read back via
|
|
``GET /children``).
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
await core_children.reorder_children(
|
|
state.database, body.ordered_ids
|
|
)
|
|
except ValueError as error:
|
|
_raise_child_error(error)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get(
|
|
"/quest-definitions",
|
|
summary="List quest definitions",
|
|
response_model=AdminQuestDefinitionListResponse,
|
|
)
|
|
async def admin_list_quest_definitions(
|
|
request: Request,
|
|
) -> AdminQuestDefinitionListResponse:
|
|
"""Return every quest definition in rising id order.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: one
|
|
:func:`nestquest_core.quest_definitions.list_all_definitions` call,
|
|
active AND inactive definitions included (the admin screen manages
|
|
both), each serialized exactly as the create/edit responses are.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
bundles = await core_quest_definitions.list_all_definitions(
|
|
state.database
|
|
)
|
|
return AdminQuestDefinitionListResponse(
|
|
definitions=[_quest_definition_response(bundle) for bundle in bundles]
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/quest-definitions",
|
|
summary="Create a quest definition",
|
|
status_code=201,
|
|
response_model=AdminQuestDefinitionResponse,
|
|
)
|
|
async def admin_create_quest_definition(
|
|
body: AdminQuestDefinitionCreateRequest, request: Request
|
|
) -> AdminQuestDefinitionResponse:
|
|
"""Create a quest definition (rule + assignees + windows) and return it.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the body model
|
|
carries the shape, the request's ``rule`` becomes ONE
|
|
:class:`~nestquest_core.recurrence.ScheduleRule` (validated by the
|
|
model's constructor, :func:`_rule_from_request`), and the title
|
|
policy, the assignee and window policies and the atomic
|
|
rule+definition+assignees+windows persistence all live in
|
|
:func:`nestquest_core.quest_definitions.create_quest_definition`.
|
|
Create names no existing definition, so a missing definition can
|
|
never be its error: every rejection here is 422
|
|
(:func:`_raise_quest_definition_error`).
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
bundle = await core_quest_definitions.create_quest_definition(
|
|
state.database,
|
|
body.title,
|
|
_rule_from_request(body.rule),
|
|
body.assignee_child_ids,
|
|
body.windows,
|
|
description=body.description,
|
|
icon=body.icon,
|
|
skip_on_away=body.skip_on_away,
|
|
)
|
|
except ValueError as error:
|
|
_raise_quest_definition_error(error)
|
|
return _quest_definition_response(bundle)
|
|
|
|
|
|
@router.post(
|
|
"/quest-definitions/occurrences-preview",
|
|
summary="Preview the next occurrence dates of a schedule rule",
|
|
response_model=AdminOccurrencesPreviewResponse,
|
|
)
|
|
async def admin_preview_occurrences(
|
|
body: AdminOccurrencesPreviewRequest, request: Request
|
|
) -> AdminOccurrencesPreviewResponse:
|
|
"""Return the first ``count`` dates the supplied rule fires on.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the ``rule``
|
|
becomes ONE :class:`~nestquest_core.recurrence.ScheduleRule`
|
|
(:func:`_rule_from_request`, the create/edit path), and the dates
|
|
come from ONE :func:`nestquest_core.recurrence.occurrences_between`
|
|
call over the bounded window ``[start_date, start_date + 366
|
|
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
|
|
read — truncated to ``count``. Every core ``ValueError`` is 422
|
|
(:func:`_raise_preview_error`). The only database access is the
|
|
settings read behind the household clock.
|
|
"""
|
|
start_date = (
|
|
body.start_date
|
|
if body.start_date is not None
|
|
else (
|
|
await _household_today(request.app.state.db.database)
|
|
).isoformat()
|
|
)
|
|
try:
|
|
rule = _rule_from_request(body.rule)
|
|
dates = core_recurrence.occurrences_between(
|
|
rule, start_date, _preview_window_end(start_date)
|
|
)
|
|
except ValueError as error:
|
|
_raise_preview_error(error)
|
|
return AdminOccurrencesPreviewResponse(dates=dates[: body.count])
|
|
|
|
|
|
@router.patch(
|
|
"/quest-definitions/{definition_id}",
|
|
summary="Edit a quest definition",
|
|
response_model=AdminQuestDefinitionResponse,
|
|
)
|
|
async def admin_edit_quest_definition(
|
|
definition_id: int,
|
|
body: AdminQuestDefinitionEditRequest,
|
|
request: Request,
|
|
) -> AdminQuestDefinitionResponse:
|
|
"""Edit a quest definition; only supplied fields change.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the supplied
|
|
fields are forwarded with ``exclude_unset`` semantics so an omitted
|
|
field is never passed to
|
|
:func:`nestquest_core.quest_definitions.edit_quest_definition` and
|
|
stays unchanged; a supplied ``windows`` list replaces the WHOLE
|
|
window set in the core, a supplied ``assignee_child_ids`` list
|
|
replaces the WHOLE assignee set in the same core transaction, and a
|
|
supplied ``rule`` is rebuilt into a ScheduleRule
|
|
(:func:`_rule_from_request`, validated by the model). An explicit
|
|
``null`` ``rule``/``windows``/``assignee_child_ids`` reaches the
|
|
core as None and is rejected there (422), while an explicit ``null``
|
|
``description``/``icon`` clears the stored value — the core edit
|
|
path supports clearing, unlike the children edit. An unknown
|
|
definition id is mapped to 404 and any other core ``ValueError``
|
|
to 422 (:func:`_raise_quest_definition_error`).
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
supplied = body.model_dump(exclude_unset=True)
|
|
try:
|
|
if isinstance(body.rule, AdminRuleRequest):
|
|
supplied["rule"] = _rule_from_request(body.rule)
|
|
bundle = await core_quest_definitions.edit_quest_definition(
|
|
state.database, definition_id, **supplied
|
|
)
|
|
except ValueError as error:
|
|
_raise_quest_definition_error(error)
|
|
return _quest_definition_response(bundle)
|
|
|
|
|
|
@router.patch(
|
|
"/quest-definitions/{definition_id}/active",
|
|
summary="Set a quest definition's active flag",
|
|
response_model=AdminQuestDefinitionResponse,
|
|
)
|
|
async def admin_set_quest_definition_active(
|
|
definition_id: int,
|
|
body: AdminQuestDefinitionActiveRequest,
|
|
request: Request,
|
|
) -> AdminQuestDefinitionResponse:
|
|
"""Deactivate (or reactivate) a definition; it is never deleted here.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: ``is_active``
|
|
goes straight to
|
|
:func:`nestquest_core.quest_definitions.set_quest_definition_active`
|
|
— the row, rule, assignees and windows all survive, so completion
|
|
history keeps its references (the hybrid DELETE route is the only
|
|
hard-delete path, and only for a definition without history). An unknown definition id is mapped to 404
|
|
(:func:`_raise_quest_definition_error`); the body model already
|
|
makes the core's real-bool check unreachable from this route.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
bundle = await core_quest_definitions.set_quest_definition_active(
|
|
state.database, definition_id, body.is_active
|
|
)
|
|
except ValueError as error:
|
|
_raise_quest_definition_error(error)
|
|
return _quest_definition_response(bundle)
|
|
|
|
|
|
@router.delete(
|
|
"/quest-definitions/{definition_id}",
|
|
summary="Delete a quest definition, or retire it if it has history",
|
|
response_model=AdminQuestDefinitionDeleteResponse,
|
|
)
|
|
async def admin_delete_quest_definition(
|
|
definition_id: int, request: Request
|
|
) -> AdminQuestDefinitionDeleteResponse:
|
|
"""Hard-delete a history-free definition; retire one with history.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the id goes
|
|
straight to
|
|
:func:`nestquest_core.quest_definitions.delete_quest_definition`,
|
|
which decides delete-vs-retire atomically in the core. An unknown
|
|
definition id is mapped to 404
|
|
(:func:`_raise_quest_definition_error`).
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
result = await core_quest_definitions.delete_quest_definition(
|
|
state.database, definition_id
|
|
)
|
|
except ValueError as error:
|
|
_raise_quest_definition_error(error)
|
|
return AdminQuestDefinitionDeleteResponse(
|
|
definition_id=result.definition_id,
|
|
deleted=result.deleted,
|
|
retired=result.retired,
|
|
definition=(
|
|
None
|
|
if result.definition is None
|
|
else _quest_definition_response(result.definition)
|
|
),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/children/{child_id}/presence-patterns",
|
|
summary="List a child's repeating presence patterns",
|
|
response_model=AdminPresencePatternListResponse,
|
|
)
|
|
async def admin_list_presence_patterns(
|
|
child_id: int, request: Request
|
|
) -> AdminPresencePatternListResponse:
|
|
"""Return the child's presence patterns, ordered by id.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: one
|
|
:func:`nestquest_core.presence_management.list_presence_patterns`
|
|
call, each pattern serialized exactly as the POST response is; a
|
|
child without one answers ``{"patterns": []}`` (present every day).
|
|
Errors are mapped by :func:`_raise_child_error`: an unknown child
|
|
is 404.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
records = await core_presence_management.list_presence_patterns(
|
|
state.database, child_id
|
|
)
|
|
except ValueError as error:
|
|
_raise_child_error(error)
|
|
return AdminPresencePatternListResponse(
|
|
patterns=[_presence_pattern_response(record) for record in records]
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/children/{child_id}/presence-patterns",
|
|
summary="Add a repeating presence pattern to a child",
|
|
status_code=201,
|
|
response_model=AdminPresencePatternResponse,
|
|
)
|
|
async def admin_create_presence_pattern(
|
|
child_id: int, body: AdminPresencePatternCreateRequest, request: Request
|
|
) -> AdminPresencePatternResponse:
|
|
"""Add one repeating presence pattern to a child.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the body model
|
|
carries the shape, and the name, kind, cycle-length, anchor-date
|
|
and pattern-coverage policies all live in the
|
|
:class:`~nestquest_core.presence.PresencePattern` constructor the
|
|
core builds the pattern with; the core regenerates the child's
|
|
future instances so they track the new presence. Errors are
|
|
mapped by :func:`_raise_child_error`: an unknown child is 404, a
|
|
rejected pattern 422.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
record = await core_presence_management.create_presence_pattern(
|
|
state.database,
|
|
child_id,
|
|
body.name,
|
|
body.kind,
|
|
body.cycle_length_weeks,
|
|
body.anchor_date,
|
|
body.pattern,
|
|
)
|
|
except ValueError as error:
|
|
_raise_child_error(error)
|
|
return _presence_pattern_response(record)
|
|
|
|
|
|
@router.patch(
|
|
"/presence-patterns/{pattern_id}",
|
|
summary="Edit a repeating presence pattern",
|
|
response_model=AdminPresencePatternResponse,
|
|
)
|
|
async def admin_edit_presence_pattern(
|
|
pattern_id: int, body: AdminPresencePatternEditRequest, request: Request
|
|
) -> AdminPresencePatternResponse:
|
|
"""Edit one presence pattern; only supplied fields change.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the supplied
|
|
fields are forwarded with ``exclude_unset`` semantics to
|
|
:func:`nestquest_core.presence_management.update_presence_pattern`,
|
|
which rebuilds the whole pattern through the model (an explicit
|
|
``null`` is rejected there) and regenerates the pattern's child.
|
|
Error mapping (:func:`_raise_presence_pattern_error`): an unknown
|
|
pattern id is 404; a rejected edit is 422.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
record = await core_presence_management.update_presence_pattern(
|
|
state.database,
|
|
pattern_id,
|
|
**body.model_dump(exclude_unset=True),
|
|
)
|
|
except ValueError as error:
|
|
_raise_presence_pattern_error(error)
|
|
return _presence_pattern_response(record)
|
|
|
|
|
|
@router.delete(
|
|
"/presence-patterns/{pattern_id}",
|
|
summary="Delete a repeating presence pattern",
|
|
)
|
|
async def admin_delete_presence_pattern(
|
|
pattern_id: int, request: Request
|
|
) -> dict[str, str]:
|
|
"""Delete one presence pattern.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the id goes
|
|
straight to
|
|
:func:`nestquest_core.presence_management.delete_presence_pattern`,
|
|
which looks the pattern up first (an unknown id is refused BEFORE
|
|
any delete), removes it and regenerates ITS child's future
|
|
instances. On success the route answers ``{"status": "ok"}``.
|
|
|
|
Error mapping (:func:`_raise_presence_pattern_error`): an unknown
|
|
pattern id is 404; every other core ``ValueError`` is 422.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
await core_presence_management.delete_presence_pattern(
|
|
state.database, pattern_id
|
|
)
|
|
except ValueError as error:
|
|
_raise_presence_pattern_error(error)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post(
|
|
"/presence-overrides",
|
|
summary="Create a date-range presence override",
|
|
status_code=201,
|
|
response_model=AdminPresenceOverrideResponse,
|
|
)
|
|
async def admin_create_presence_override(
|
|
body: AdminPresenceOverrideCreateRequest, request: Request
|
|
) -> AdminPresenceOverrideResponse:
|
|
"""Create one date-range presence override and return it with its id.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the body model
|
|
carries the shape, and the date/order/bool/note policies live in
|
|
the :class:`~nestquest_core.presence.PresenceOverride` constructor
|
|
while the same-child no-overlap policy lives in the core's create
|
|
path — which then regenerates the child's future instances so an
|
|
absent range removes them and a present range restores them. The
|
|
response carries the stored ``id``, the handle
|
|
``DELETE /presence-overrides/{id}`` takes.
|
|
|
|
Errors are mapped by :func:`_raise_child_error`: an unknown child
|
|
is 404; a rejected override (malformed or inverted dates, an
|
|
overlapping range) is 422.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
record = await core_presence_management.create_presence_override(
|
|
state.database,
|
|
body.child_id,
|
|
body.start_date,
|
|
body.end_date,
|
|
body.is_present,
|
|
note=body.note,
|
|
)
|
|
except ValueError as error:
|
|
_raise_child_error(error)
|
|
return _presence_override_response(record)
|
|
|
|
|
|
@router.delete(
|
|
"/presence-overrides/{override_id}",
|
|
summary="Delete a presence override",
|
|
)
|
|
async def admin_delete_presence_override(
|
|
override_id: int, request: Request
|
|
) -> dict[str, str]:
|
|
"""Delete one presence override; children are never hard-deleted.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the id goes
|
|
straight to
|
|
:func:`nestquest_core.presence_management.delete_presence_override`,
|
|
which looks the override up first (an unknown id is refused BEFORE
|
|
any delete), removes it and regenerates ITS child's future
|
|
instances — the id, never a caller-supplied child, decides which
|
|
child's presence is rebuilt. On success the route answers
|
|
``{"status": "ok"}``.
|
|
|
|
Error mapping (:func:`_raise_presence_override_error`): an unknown
|
|
override id is 404; every other core ``ValueError`` is 422.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
await core_presence_management.delete_presence_override(
|
|
state.database, override_id
|
|
)
|
|
except ValueError as error:
|
|
_raise_presence_override_error(error)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.post(
|
|
"/presence-overrides/consequence",
|
|
summary="Count the upcoming instances a presence override would remove",
|
|
response_model=AdminPresenceOverrideConsequenceResponse,
|
|
)
|
|
async def admin_presence_override_consequence(
|
|
body: AdminPresenceOverrideConsequenceRequest, request: Request
|
|
) -> AdminPresenceOverrideConsequenceResponse:
|
|
"""Return how many upcoming instances saving the override removes.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: ONE
|
|
:func:`nestquest_core.presence_management.preview_presence_override_consequence`
|
|
call, anchored on ONE :func:`_local_now` clock read, which compares
|
|
the materialization walk's output for the child with and without
|
|
the proposed override and excludes instances that already have a
|
|
completion event (D-005). Nothing is written.
|
|
|
|
Errors are mapped by :func:`_raise_child_error`: an unknown child
|
|
is 404; a malformed or inverted range is 422.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
removed = await (
|
|
core_presence_management.preview_presence_override_consequence(
|
|
state.database,
|
|
body.child_id,
|
|
body.start_date,
|
|
body.end_date,
|
|
body.is_present,
|
|
today=await _household_today(state.database),
|
|
)
|
|
)
|
|
except ValueError as error:
|
|
_raise_child_error(error)
|
|
return AdminPresenceOverrideConsequenceResponse(removed=removed)
|
|
|
|
|
|
@router.get(
|
|
"/presence-overrides",
|
|
summary="List presence overrides",
|
|
response_model=AdminPresenceOverrideListResponse,
|
|
)
|
|
async def admin_list_presence_overrides(
|
|
request: Request,
|
|
child_id: int | None = None,
|
|
start: str | None = None,
|
|
end: str | None = None,
|
|
) -> AdminPresenceOverrideListResponse:
|
|
"""Return the household's presence overrides, optionally filtered.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the optional
|
|
``child_id``, ``start`` and ``end`` query parameters go straight to
|
|
:func:`nestquest_core.presence_management.list_presence_overrides`
|
|
— the date policy (strict ``YYYY-MM-DD``, ``end >= start``) and the
|
|
overlap rule live in the core — and each record is serialized
|
|
exactly as the POST response is, ordered by start date, child id,
|
|
id. Errors are mapped by :func:`_raise_child_error`: an unknown
|
|
``child_id`` is 404, a rejected date or range is 422.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
try:
|
|
records = await core_presence_management.list_presence_overrides(
|
|
state.database, child_id=child_id, start=start, end=end
|
|
)
|
|
except ValueError as error:
|
|
_raise_child_error(error)
|
|
return AdminPresenceOverrideListResponse(
|
|
overrides=[_presence_override_response(record) for record in records]
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/instances/{instance_id}/uncomplete",
|
|
summary="Reverse one completion (append a reversal event)",
|
|
response_model=AdminUncompleteResponse,
|
|
)
|
|
async def admin_uncomplete_instance(
|
|
instance_id: int,
|
|
request: Request,
|
|
claims: Annotated[dict[str, object], Depends(require_admin)],
|
|
) -> AdminUncompleteResponse:
|
|
"""Reverse one completion; the original event is never touched.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). ``claims`` is the SAME
|
|
verified-claims dict that dependency produced (FastAPI's
|
|
per-request dependency cache), and the reversal is attributed to
|
|
it: ``actor_source="user"`` with ``actor_user_id`` = the token's
|
|
``sub`` — the D-008 user-actor shape, ``actor_child_id`` never
|
|
sent — so the completion log records WHICH admin reversed it.
|
|
|
|
Thin adapter: ONE timezone-aware clock read (:func:`_local_now`)
|
|
and ONE :func:`nestquest_core.completion.uncomplete_instance` call
|
|
— the append-only discipline (a reversal APPENDS an ``uncompleted``
|
|
event after the original ``completed`` row, which is never edited
|
|
or deleted), the already-open no-op and the derived-state
|
|
recomputation all live in the core layer.
|
|
|
|
On an ACTUAL reversal (``CompletionResult.appended`` True) the
|
|
route publishes the ``nestquest_quest_uncompleted`` transition on
|
|
the SSE stream through
|
|
:func:`api.transitions.publish_quest_uncompleted` (the payload is
|
|
built by the core builder, never hand-built here; fan-out is
|
|
fire-and-forget, see api/events.py). An already-open no-op
|
|
(``appended`` False) publishes nothing.
|
|
|
|
Response: the derived ``state`` after the reversal — ``open``, or
|
|
``missed`` when the instance is past due — and whether THIS call
|
|
``appended``. Error mapping (:func:`_raise_instance_error`): an
|
|
unknown instance is 404; every other core ``ValueError`` is 422.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
database = state.database
|
|
_settings, now = await _household_clock(database)
|
|
sub = claims.get("sub")
|
|
try:
|
|
result = await core_completion.uncomplete_instance(
|
|
database,
|
|
instance_id,
|
|
actor_source="user",
|
|
actor_user_id=sub if isinstance(sub, str) else None,
|
|
now=now,
|
|
)
|
|
except ValueError as error:
|
|
_raise_instance_error(error)
|
|
if result.appended:
|
|
# An ACTUAL reversal: announce the transition once. A no-op
|
|
# (already open) publishes nothing.
|
|
await api_transitions.publish_quest_uncompleted(
|
|
database, instance_id, request.app.state.publisher
|
|
)
|
|
return AdminUncompleteResponse(
|
|
instance_id=instance_id,
|
|
state=str(result),
|
|
appended=result.appended,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/regenerate",
|
|
summary="Re-materialize the rolling horizon for one scope",
|
|
response_model=AdminRegenerateResponse,
|
|
)
|
|
async def admin_regenerate(
|
|
body: AdminRegenerateRequest, request: Request
|
|
) -> AdminRegenerateResponse:
|
|
"""Re-materialize the rolling horizon for the requested scope.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter over the SAME
|
|
materialization path the integration's config/presence changes
|
|
trigger, one scope per request:
|
|
|
|
- ``household`` (the default): ONE
|
|
:func:`nestquest_core.materialize.materialize` call over
|
|
``[today, today + const.DEFAULT_HORIZON_DAYS]`` — the core's
|
|
default horizon, which sizes the window here because the API
|
|
config carries no horizon knob of its own. The walk is an
|
|
idempotent upsert: re-running refreshes rows in place and never
|
|
duplicates an instance.
|
|
- ``definition``: ONE existence read
|
|
(:class:`~nestquest_core.dao_rules.QuestDefinitionsDao.get` — an
|
|
unknown id is 404 BEFORE any core call, which the core's
|
|
regenerate path itself takes no stance on), then
|
|
:func:`nestquest_core.materialize.regenerate_for_definition`.
|
|
- ``child``: the same shape through
|
|
:class:`~nestquest_core.dao_children.ChildrenDao.get` and
|
|
:func:`nestquest_core.materialize.regenerate_for_child`.
|
|
|
|
The ONE clock read (:func:`_local_now`) threads the household-local
|
|
``today`` into every core call so the walk's no-past guard and the
|
|
delete cutoff share one anchor. Every core call returns the
|
|
upsert ``count``, which the response passes through. Error
|
|
mapping (:func:`_raise_regenerate_error`): every core
|
|
``ValueError`` is 422; the scope-id 404s are decided above.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
database = state.database
|
|
today = await _household_today(state.database)
|
|
|
|
if body.scope == "household":
|
|
end_date = today + datetime.timedelta(
|
|
days=core_const.DEFAULT_HORIZON_DAYS
|
|
)
|
|
try:
|
|
count = await core_materialize.materialize(
|
|
database,
|
|
today.isoformat(),
|
|
end_date.isoformat(),
|
|
today=today,
|
|
)
|
|
except ValueError as error:
|
|
_raise_regenerate_error(error)
|
|
return AdminRegenerateResponse(
|
|
scope="household", definition_id=None, child_id=None,
|
|
count=count,
|
|
)
|
|
|
|
if body.scope == "definition":
|
|
definition_id = body.definition_id
|
|
# The request model's scope/id consistency validator already
|
|
# guarantees the id for this scope; this only narrows the type.
|
|
assert definition_id is not None
|
|
definition = await core_dao_rules.QuestDefinitionsDao(
|
|
database
|
|
).get(definition_id)
|
|
if definition is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=_QUEST_DEFINITION_NOT_FOUND_DETAIL
|
|
)
|
|
try:
|
|
count = await core_materialize.regenerate_for_definition(
|
|
database, definition_id, today=today
|
|
)
|
|
except ValueError as error:
|
|
_raise_regenerate_error(error)
|
|
return AdminRegenerateResponse(
|
|
scope="definition",
|
|
definition_id=definition_id,
|
|
child_id=None,
|
|
count=count,
|
|
)
|
|
|
|
child_id = body.child_id
|
|
# Same model guarantee as the definition scope above.
|
|
assert child_id is not None
|
|
child = await core_dao_children.ChildrenDao(database).get(child_id)
|
|
if child is None:
|
|
raise HTTPException(status_code=404, detail=_CHILD_NOT_FOUND_DETAIL)
|
|
try:
|
|
count = await core_materialize.regenerate_for_child(
|
|
database, child_id, today=today
|
|
)
|
|
except ValueError as error:
|
|
_raise_regenerate_error(error)
|
|
return AdminRegenerateResponse(
|
|
scope="child", definition_id=None, child_id=child_id, count=count
|
|
)
|
|
|
|
|
|
def _history_row_response(row: core_history.HistoryRow) -> AdminHistoryRow:
|
|
"""Serialize one core HistoryRow into the documented payload."""
|
|
return AdminHistoryRow(
|
|
occurred_at=row.occurred_at,
|
|
event_type=row.event_type,
|
|
child_name=row.child_name,
|
|
child_id=row.child_id,
|
|
quest_title=row.quest_title,
|
|
instance_id=row.instance_id,
|
|
window=row.window,
|
|
due_date=row.due_date,
|
|
due_time=row.due_time,
|
|
actor=row.actor,
|
|
was_on_time=row.was_on_time,
|
|
)
|
|
|
|
|
|
def _settings_response(
|
|
settings: core_settings.NestQuestSettings,
|
|
) -> AdminSettingsResponse:
|
|
"""Serialize one core NestQuestSettings into the documented payload.
|
|
|
|
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
|
|
stays pinned to the eleven documented fields (plus the read-only
|
|
``effective_timezone``) even if the core dataclass later grows
|
|
another one.
|
|
"""
|
|
return AdminSettingsResponse(
|
|
horizon_days=settings.horizon_days,
|
|
day_rollover_time=settings.day_rollover_time,
|
|
notify_target=settings.notify_target,
|
|
morning_summary_time=settings.morning_summary_time,
|
|
afternoon_reminder_time=settings.afternoon_reminder_time,
|
|
end_of_day_report_time=settings.end_of_day_report_time,
|
|
morning_summary_enabled=settings.morning_summary_enabled,
|
|
afternoon_reminder_enabled=settings.afternoon_reminder_enabled,
|
|
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
|
celebration_enabled=settings.celebration_enabled,
|
|
timezone=settings.timezone,
|
|
effective_timezone=host_zone.effective_timezone(settings.timezone),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/history",
|
|
summary="Query the completion history for one filter and range",
|
|
response_model=AdminHistoryResponse,
|
|
)
|
|
async def admin_history_query(
|
|
request: Request,
|
|
start: str,
|
|
end: str,
|
|
filter: str = "all",
|
|
) -> AdminHistoryResponse:
|
|
"""Return the history rows for one filter over the closed range.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: ``filter``
|
|
(default ``all``), ``start`` and ``end`` go straight to
|
|
:func:`nestquest_core.history.query_history` as STRINGS — the
|
|
filter policy (``all`` / ``reversals`` / ``missed``) and the
|
|
date-range policy (strict ``YYYY-MM-DD``, ``end >= start``) live in
|
|
the core, which raises a ``ValueError`` naming the offending field;
|
|
the handler only threads ONE clock read (:func:`_local_now`) as the
|
|
missed derivation's ``today`` and serializes the returned rows.
|
|
``start``/``end`` are required query parameters (missing is
|
|
FastAPI's 422 before any handler code runs); every core
|
|
``ValueError`` is 422 (:func:`_raise_history_error`).
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
today = await _household_today(state.database)
|
|
try:
|
|
rows = await core_history.query_history(
|
|
state.database, filter, start, end, today=today
|
|
)
|
|
except ValueError as error:
|
|
_raise_history_error(error)
|
|
return AdminHistoryResponse(
|
|
filter=filter,
|
|
start=start,
|
|
end=end,
|
|
count=len(rows),
|
|
rows=[_history_row_response(row) for row in rows],
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/history.csv",
|
|
summary="Export the completion history as CSV",
|
|
response_class=Response,
|
|
)
|
|
async def admin_history_csv(
|
|
request: Request,
|
|
start: str,
|
|
end: str,
|
|
filter: str = "all",
|
|
) -> Response:
|
|
"""Export the SAME filtered history range as a CSV attachment.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter over ONE
|
|
:func:`nestquest_core.history.export_history_csv` call — the same
|
|
query path the JSON route uses, so the CSV can never drift from the
|
|
query's filtered range — with the core's validation policy and the
|
|
:mod:`csv`-based serialization (stable documented header row, RFC
|
|
4180 quoting) living in the core. The answer is ``text/csv`` with
|
|
a ``Content-Disposition`` attachment filename naming the range;
|
|
the range is core-validated strict ``YYYY-MM-DD`` by the time the
|
|
filename is built, so it is injection-safe. Error mapping is the
|
|
JSON route's (:func:`_raise_history_error`).
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
today = await _household_today(state.database)
|
|
try:
|
|
csv_text = await core_history.export_history_csv(
|
|
state.database, filter, start, end, today=today
|
|
)
|
|
except ValueError as error:
|
|
_raise_history_error(error)
|
|
filename = f"nestquest-history-{start}-to-{end}.csv"
|
|
return Response(
|
|
content=csv_text,
|
|
media_type="text/csv",
|
|
headers={
|
|
"Content-Disposition": f'attachment; filename="{filename}"'
|
|
},
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/settings",
|
|
summary="Read the effective settings",
|
|
response_model=AdminSettingsResponse,
|
|
)
|
|
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
|
|
"""Return the current effective settings (all eleven documented fields).
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: ONE
|
|
:func:`nestquest_core.settings_store.load_settings` call — the API
|
|
owns settings in the database (the integration's HA options remain
|
|
a temporary separate source until Feature 18 wires the client to
|
|
the API), and the stored-document decode, the per-field validation
|
|
and the defaults-for-absent-fields fallback all live in the core.
|
|
A corrupt stored document never fails the read: the core falls
|
|
back to the defaults (per field where possible).
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
settings = await core_settings_store.load_settings(state.database)
|
|
return _settings_response(settings)
|
|
|
|
|
|
@router.patch(
|
|
"/settings",
|
|
summary="Update the supplied settings fields",
|
|
response_model=AdminSettingsResponse,
|
|
)
|
|
async def admin_update_settings(
|
|
changes: dict[str, object], request: Request
|
|
) -> AdminSettingsResponse:
|
|
"""Update the supplied settings fields and return the new state.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: the body — a
|
|
JSON object of settings field names to new values — goes STRAIGHT
|
|
to :func:`nestquest_core.settings_store.update_settings`, which
|
|
rejects an unknown field, resolves each supplied field through the
|
|
core settings validators (a JSON ``null`` resets its field to the
|
|
default) and merges onto the current effective settings only after
|
|
every value has validated — so a rejected update never changes the
|
|
stored settings. Every core ``ValueError`` is 422
|
|
(:func:`_raise_settings_error`); anything else re-raises.
|
|
|
|
The write holds the missed-sweep scheduler's ``settings_lock`` and
|
|
then tells it (:meth:`~api.scheduler.MissedSweepScheduler.settings_changed`),
|
|
so a saved ``timezone`` or ``day_rollover_time`` re-plans a sleeping
|
|
scheduler (an app without one — no lifespan — just writes).
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
scheduler = getattr(request.app.state, "sweep_scheduler", None)
|
|
try:
|
|
if scheduler is None:
|
|
settings = await core_settings_store.update_settings(
|
|
state.database, changes
|
|
)
|
|
else:
|
|
async with scheduler.settings_lock:
|
|
settings = await core_settings_store.update_settings(
|
|
state.database, changes
|
|
)
|
|
scheduler.settings_changed(settings)
|
|
except ValueError as error:
|
|
_raise_settings_error(error)
|
|
return _settings_response(settings)
|
|
|
|
|
|
@router.post(
|
|
"/missed-sweep",
|
|
summary="Run the nightly missed-quest sweep now",
|
|
response_model=AdminMissedSweepResponse,
|
|
)
|
|
async def admin_run_missed_sweep(
|
|
request: Request,
|
|
) -> AdminMissedSweepResponse:
|
|
"""Run the missed-quest sweep and publish what it built.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter over the SAME
|
|
HA-free sweep policy the integration's rollover listener runs
|
|
(:mod:`nestquest_core.sweep`): ONE timezone-aware clock read
|
|
(:func:`_local_now`, re-expressed in the configured household
|
|
``timezone``; empty falls back to host local) pins the
|
|
household-local ``today``, and ONE
|
|
:func:`nestquest_core.sweep.run_missed_sweep` call does everything
|
|
else — the past-due open-instance query, the documented payload
|
|
build and the watermark update (a same-day rerun is an empty
|
|
no-op, and a run after downtime sweeps the accumulated window
|
|
once). The route is read-only over the domain tables: it never
|
|
mutates an instance and never writes a completion event.
|
|
|
|
Each returned ``(event_type, payload)`` pair is published DIRECTLY
|
|
on the app's ONE transition publisher (``request.app.state.publisher``,
|
|
fan-out fire-and-forget, see api/events.py) — the exact payload the
|
|
sweep built, never re-fetched and never hand-built here, the same
|
|
publish-what-the-core-built shape api/scheduler.py uses. This is a
|
|
correctness rule, not a style preference: the core commits the
|
|
idempotency watermark BEFORE returning, so a re-fetch-based publish
|
|
loop opens a loss window — a concurrent regenerate deleting the
|
|
instance, or a transient database error, would raise uncaught (a
|
|
bare 500) AFTER the watermark advanced, and no future sweep would
|
|
ever re-examine that instance. Publishing the built payload leaves
|
|
no such window, and every event of one run keeps the ONE shared
|
|
``occurred_at`` stamp the sweep computed for it. The response
|
|
reports ``fired`` — the number of transitions published. Error
|
|
mapping (:func:`_raise_sweep_error`): every core ``ValueError`` is
|
|
422; anything else re-raises.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
database = state.database
|
|
today = await _household_today(state.database)
|
|
try:
|
|
events = await core_sweep.run_missed_sweep(database, today=today)
|
|
except ValueError as error:
|
|
_raise_sweep_error(error)
|
|
for event_type, payload in events:
|
|
request.app.state.publisher.publish(event_type, payload)
|
|
return AdminMissedSweepResponse(fired=len(events))
|
|
|
|
|
|
@router.get(
|
|
"/snapshot",
|
|
summary="Today's household snapshot",
|
|
response_model=AdminSnapshotResponse,
|
|
)
|
|
async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
|
|
"""Return today's full household snapshot for the admin Today tab.
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own). Thin adapter: ONE clock read
|
|
(:func:`_local_now`) goes into
|
|
:func:`nestquest_core.snapshot.build_snapshot` against the live
|
|
database, and each child's instances are shaped with
|
|
``instance_payload(include_missed=True)`` — missed instances are
|
|
KEPT in the admin payload (D-009) — into
|
|
:class:`AdminSnapshotResponse`.
|
|
"""
|
|
state: DatabaseState = request.app.state.db
|
|
settings, now = await _household_clock(state.database)
|
|
snapshot = await core_snapshot.build_snapshot(
|
|
state.database, settings, now
|
|
)
|
|
return AdminSnapshotResponse(
|
|
today_iso=snapshot.today_iso,
|
|
cycle_day=snapshot.cycle_day,
|
|
children=[
|
|
AdminChildSnapshotPayload(
|
|
child_id=child.child_id,
|
|
child_name=child.child_name,
|
|
present=child.present,
|
|
next_present=child.next_present,
|
|
due_today=child.due_today,
|
|
completed_today=child.completed_today,
|
|
remaining_today=child.remaining_today,
|
|
completion_pct=child.completion_pct,
|
|
instances=[
|
|
AdminInstancePayload.model_validate(instance)
|
|
for instance in core_snapshot.instance_payload(
|
|
child.instances, include_missed=True
|
|
)
|
|
],
|
|
)
|
|
for child in snapshot.children
|
|
],
|
|
)
|
|
|
|
|
|
|
|
@router.get(
|
|
"/events",
|
|
summary="Stream admin transition events (SSE)",
|
|
)
|
|
async def admin_events(request: Request) -> StreamingResponse:
|
|
"""Stream the app's transition events to an admin client
|
|
(Server-Sent Events).
|
|
|
|
Requires a valid admin JWT (the router's shared
|
|
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
handler performs NO auth of its own), so an unauthenticated or
|
|
panel-token request is refused before any stream is established.
|
|
|
|
Returns the shared :func:`api.sse.transition_event_stream` response
|
|
off the app's single in-process publisher (``app.state.publisher``)
|
|
— the same publisher, framing and teardown as the panel stream.
|
|
"""
|
|
return transition_event_stream(request)
|
|
|
|
|
|
__all__ = ["router"]
|