Admin routes: quest definitions (#159)

Co-authored-by: Joshua <joshua@cnjmail.com>
Co-committed-by: Joshua <joshua@cnjmail.com>
This commit was merged in pull request #159.
This commit is contained in:
2026-09-23 01:54:41 -04:00
committed by dev_agent
parent 0b7f71831b
commit e85180d492
3 changed files with 975 additions and 8 deletions
+16
View File
@@ -128,6 +128,20 @@ core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
#: the admin plane's children routes call.
core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children")
#: The quest-definition business layer (``core.quest_definitions`` —
#: ``create_quest_definition``, ``edit_quest_definition``,
#: ``set_quest_definition_active``) the admin plane's quest-definition
#: routes call.
core_quest_definitions = importlib.import_module(
f"{_CORE_MODULE_NAME}.quest_definitions"
)
#: The recurrence model (``core.recurrence.ScheduleRule`` and
#: ``RuleValidationError``) the quest-definition routes build request
#: rules with — the model validates at construction, the routes never
#: re-implement rule validation.
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
__all__ = [
"core",
"core_children",
@@ -135,6 +149,8 @@ __all__ = [
"core_db",
"core_events",
"core_migrations",
"core_quest_definitions",
"core_recurrence",
"core_snapshot",
"core_settings",
]
+348 -8
View File
@@ -18,20 +18,47 @@ check all live THERE, never in a handler — and serializes the returned
payload (``id``, ``display_name``, ``colour``, ``avatar_ref``,
``sort_order``, ``is_active``).
Error mapping (every children route, deliberately narrow):
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. Assignment is NOT settable
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):
- 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.
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 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) — 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.
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) — 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
@@ -49,7 +76,11 @@ from pydantic import BaseModel
from api.auth import require_admin
from api.database import DatabaseState
from api.nestquest_core import core_children
from api.nestquest_core import (
core_children,
core_quest_definitions,
core_recurrence,
)
#: All admin-plane routes share this router; the ONE admin dependency
#: (service-token refusal + JWT verification + nestquest-admins group
@@ -91,6 +122,60 @@ class AdminChildListResponse(BaseModel):
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
rule: AdminRuleResponse
assignees: list[AdminAssigneeResponse]
windows: list[AdminDefinitionWindowResponse]
class AdminChildCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/children``.
@@ -138,6 +223,77 @@ class AdminChildReorderRequest(BaseModel):
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
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.
``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.
"""
title: str
rule: AdminRuleRequest
assignee_child_ids: list[int]
windows: list[str | tuple[str, str | None]]
description: str | None = None
icon: str | None = None
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`` or ``windows`` is passed and rejected by the core. A
supplied ``windows`` list REPLACES the whole window set, and
``assignees`` are deliberately absent — assignment is not settable
through this route.
"""
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
class AdminQuestDefinitionActiveRequest(BaseModel):
"""The body of ``PATCH /api/v1/admin/quest-definitions/{id}/active``."""
is_active: bool
#: 404 detail for a child id the core layer reports as non-existent.
_CHILD_NOT_FOUND_DETAIL = "Child not found"
@@ -172,6 +328,76 @@ def _child_response(record: core_children.ChildRecord) -> AdminChildResponse:
)
#: 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
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=bundle.definition.icon,
is_active=bundle.definition.is_active,
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
],
)
@router.get("/ping", summary="Admin plane probe")
async def admin_ping(
claims: Annotated[dict[str, object], Depends(require_admin)],
@@ -341,4 +567,118 @@ async def admin_reorder_children(
return {"status": "ok"}
@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,
)
except ValueError as error:
_raise_quest_definition_error(error)
return _quest_definition_response(bundle)
@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, and a supplied ``rule`` is rebuilt into a
ScheduleRule (:func:`_rule_from_request`, validated by the model).
An explicit ``null`` ``rule``/``windows`` 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.
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`
— deactivation is the only removal path (the row, rule, assignees
and windows all survive, so completion history keeps its
references). 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)
__all__ = ["router"]