345 lines
12 KiB
Python
345 lines
12 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``).
|
|
|
|
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, NoReturn
|
|
|
|
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
|
|
#: 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 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)],
|
|
) -> 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"}
|
|
|
|
|
|
__all__ = ["router"]
|