Admin routes: presence schedule and override (#160)

This commit was merged in pull request #160.
This commit is contained in:
2026-09-23 02:26:17 -04:00
parent e85180d492
commit a3773fec8c
6 changed files with 1040 additions and 35 deletions
+22
View File
@@ -142,13 +142,35 @@ core_quest_definitions = importlib.import_module(
#: re-implement rule validation.
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
#: The presence model (``core.presence.PresenceEngine``,
#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's
#: presence routes serialize and the tests resolve presence with.
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
#: The presence-write business layer
#: (``core.presence_management.set_presence_schedule`` et al.) the
#: admin plane's presence routes call — the ONE implementation the HA
#: services share.
core_presence_management = importlib.import_module(
f"{_CORE_MODULE_NAME}.presence_management"
)
#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests
#: read stored schedules and overrides back through.
core_dao_presence = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_presence"
)
__all__ = [
"core",
"core_children",
"core_completion",
"core_dao_presence",
"core_db",
"core_events",
"core_migrations",
"core_presence",
"core_presence_management",
"core_quest_definitions",
"core_recurrence",
"core_snapshot",
+264 -3
View File
@@ -35,8 +35,32 @@ through the API: the core's edit path takes no assignees. The edit
route forwards only SUPPLIED fields (``exclude_unset``), like the
children edit.
Error mapping (every children and quest-definition route, deliberately
narrow):
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:
- ``PUT /children/{child_id}/presence-schedule`` sets (UPSERTS) the
child's repeating schedule: the body's ``cycle_length_weeks``,
``anchor_date`` and ``pattern`` go straight into ONE
:class:`~nestquest_core.presence.PresenceSchedule` (the model
validates cycle length, the strict anchor date and the pattern's
week coverage at construction), the core upserts by child (setting
again REPLACES the one schedule a child can have) and regenerates
the child's future instances; the response is the STORED schedule
decoded back through the model.
- ``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"}``.
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
@@ -55,10 +79,21 @@ narrow):
``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) — is mapped to 422: the body was well-formed JSON but
wrong-typed); a rejected presence schedule or override (bad cycle
length, a pattern not covering the cycle's weeks, 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 (setting a schedule 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).
PATCH edit semantics: the body model's fields are optional and only
SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so
@@ -78,6 +113,8 @@ from api.auth import require_admin
from api.database import DatabaseState
from api.nestquest_core import (
core_children,
core_presence,
core_presence_management,
core_quest_definitions,
core_recurrence,
)
@@ -294,6 +331,70 @@ class AdminQuestDefinitionActiveRequest(BaseModel):
is_active: bool
class AdminPresenceScheduleRequest(BaseModel):
"""The body of ``PUT /api/v1/admin/children/{child_id}/presence-schedule``.
The shape of :class:`~nestquest_core.presence.PresenceSchedule` —
nothing more. ``pattern`` maps each cycle week index (JSON object
keys, coerced to ints by the model) to that week's present weekdays
(Monday=0); every week of the cycle must be covered, which the
PRESENCE MODEL enforces at construction, not this shape. A week
with an empty list means absent every day of that week.
"""
cycle_length_weeks: int
anchor_date: str
pattern: dict[int, list[int]]
class AdminPresenceScheduleResponse(BaseModel):
"""A child's repeating presence schedule as the admin plane serializes it.
The stored schedule decoded back through
:meth:`nestquest_core.presence.PresenceSchedule.decode`: each
pattern week is the sorted list of present weekdays, and the child
has exactly ONE schedule (setting again replaces it).
"""
child_id: int
cycle_length_weeks: int
anchor_date: str
pattern: dict[int, list[int]]
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
#: 404 detail for a child id the core layer reports as non-existent.
_CHILD_NOT_FOUND_DETAIL = "Child not found"
@@ -355,6 +456,28 @@ def _raise_quest_definition_error(error: ValueError) -> NoReturn:
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
schedule 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
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
"""Build the core ScheduleRule from the request's ``rule`` object.
@@ -398,6 +521,34 @@ def _quest_definition_response(
)
def _presence_schedule_response(
schedule: core_presence.PresenceSchedule,
) -> AdminPresenceScheduleResponse:
"""Serialize one decoded core PresenceSchedule into the payload."""
return AdminPresenceScheduleResponse(
child_id=schedule.child_id,
cycle_length_weeks=schedule.cycle_length_weeks,
anchor_date=schedule.anchor_date.isoformat(),
pattern={
week: sorted(days) for week, days in schedule.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)],
@@ -681,4 +832,114 @@ async def admin_set_quest_definition_active(
return _quest_definition_response(bundle)
@router.put(
"/children/{child_id}/presence-schedule",
summary="Set a child's repeating presence schedule",
response_model=AdminPresenceScheduleResponse,
)
async def admin_set_presence_schedule(
child_id: int, body: AdminPresenceScheduleRequest, request: Request
) -> AdminPresenceScheduleResponse:
"""Set (upsert) a child's repeating presence schedule.
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 cycle-length, anchor-date and
pattern-coverage policies all live in the
:class:`~nestquest_core.presence.PresenceSchedule` constructor the
core builds the schedule with — setting again REPLACES the child's
one schedule, and 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
schedule 422.
"""
state: DatabaseState = request.app.state.db
try:
schedule = await core_presence_management.set_presence_schedule(
state.database,
child_id,
body.cycle_length_weeks,
body.anchor_date,
body.pattern,
)
except ValueError as error:
_raise_child_error(error)
return _presence_schedule_response(schedule)
@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"}
__all__ = ["router"]