Admin routes: children (#158)
This commit was merged in pull request #158.
This commit is contained in:
@@ -124,8 +124,13 @@ core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events")
|
||||
#: the snapshot builder's ``settings`` parameter takes.
|
||||
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
|
||||
|
||||
#: The children business layer (``core.children.create_child`` et al.)
|
||||
#: the admin plane's children routes call.
|
||||
core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children")
|
||||
|
||||
__all__ = [
|
||||
"core",
|
||||
"core_children",
|
||||
"core_completion",
|
||||
"core_db",
|
||||
"core_events",
|
||||
|
||||
+286
-7
@@ -8,20 +8,48 @@ 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.
|
||||
|
||||
This task is the AUTH probe only: the ping route is a thin adapter
|
||||
that performs NO business logic and NO auth of its own — it reuses the
|
||||
router's already-verified claims (FastAPI caches a dependency's result
|
||||
per request, so the check runs exactly once) and returns the status
|
||||
plus the token's subject.
|
||||
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``).
|
||||
|
||||
Error mapping (every children route, deliberately narrow):
|
||||
|
||||
- Malformed input the body model or the path parser rejects (a missing
|
||||
or wrong-typed field, a non-integer child 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.
|
||||
- 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) — 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.
|
||||
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.auth import require_admin
|
||||
from api.database import DatabaseState
|
||||
from api.nestquest_core import core_children
|
||||
|
||||
#: All admin-plane routes share this router; the ONE admin dependency
|
||||
#: (service-token refusal + JWT verification + nestquest-admins group
|
||||
@@ -42,6 +70,108 @@ class AdminPingResponse(BaseModel):
|
||||
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 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]
|
||||
|
||||
|
||||
#: 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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ping", summary="Admin plane probe")
|
||||
async def admin_ping(
|
||||
claims: Annotated[dict[str, object], Depends(require_admin)],
|
||||
@@ -62,4 +192,153 @@ async def admin_ping(
|
||||
)
|
||||
|
||||
|
||||
@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"}
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
Reference in New Issue
Block a user