Add core presence write layer and admin presence routes
The presence business operations (schedule upsert, override
create/delete, each followed by regenerate_for_child) lived only in the
HA-coupled service handlers, so the API admin plane could not reuse
them. Extract them into core/presence_management.py — model-validated
PresenceSchedule/PresenceOverride construction, the typed DAO calls
and the regeneration side effect in ONE Home-Assistant-free
implementation — and reduce the three HA service handlers to thin
wrappers over it.
Expose the admin plane's presence routes on the existing admin router
(PUT /children/{child_id}/presence-schedule, POST
/presence-overrides, DELETE /presence-overrides/{override_id}): the
body models carry only the shape, validation stays in the presence
model, and the error mapping stays narrow (unknown child/override 404,
rejected schedule/override 422). Tests drive all three routes as an
admin, prove the schedule upserts in place, an override is created and
then deleted, and the child's resolved presence (PresenceEngine)
changes accordingly.
This commit is contained in:
@@ -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
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Business layer for presence writes (Feature 09, API admin plane).
|
||||
|
||||
Sits between the write callers (the Feature 09 HA service handlers and
|
||||
the API admin plane's presence routes) and the typed presence DAOs plus
|
||||
the pure presence model: callers get the model validation, the upsert /
|
||||
create / delete operations and the regeneration side effect here, and
|
||||
never touch the DAO or SQL directly. This is the ONE implementation of
|
||||
these business rules — every caller constructs the same
|
||||
:class:`~.presence.PresenceSchedule` / :class:`~.presence.PresenceOverride`
|
||||
models (whose constructors are total, so no re-validation happens in a
|
||||
handler) and gets the same post-write
|
||||
:func:`~.materialize.regenerate_for_child`, so a child's future quest
|
||||
instances track its changed presence no matter which plane issued the
|
||||
write. Like the model it is Home-Assistant-free: ``today`` is a plain
|
||||
calendar date threaded through to the regeneration, defaulting to the
|
||||
host clock there.
|
||||
|
||||
Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence
|
||||
schedule — setting again REPLACES the existing row rather than
|
||||
duplicating it. Overrides are deletable by design (children are not —
|
||||
the children layer owns that guardrail). Deleting a nonexistent
|
||||
override raises ValueError prefixed ``override_id:`` so a caller can
|
||||
distinguish "unknown id" from a rejected argument the same way the
|
||||
quest-definitions layer prefixes ``definition_id:``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from .dao_presence import (
|
||||
PresenceOverrideRecord,
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
from .db import NestQuestDatabase
|
||||
from .materialize import regenerate_for_child
|
||||
from .presence import PresenceOverride, PresenceSchedule
|
||||
|
||||
|
||||
def _validate_override_id(value: object) -> int:
|
||||
"""Reject non-int override ids (bools would silently address id 1)."""
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"override_id must be an integer, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def set_presence_schedule(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
cycle_length_weeks: int,
|
||||
anchor_date: object,
|
||||
pattern: dict[int, object],
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> PresenceSchedule:
|
||||
"""Set (upsert) a child's repeating presence schedule.
|
||||
|
||||
The :class:`~.presence.PresenceSchedule` model validates at
|
||||
construction — cycle length 1..4, a strict calendar anchor date,
|
||||
and a pattern covering week indices 0..n-1 with weekday sets — so
|
||||
no invalid schedule reaches the DAO. The encoded pattern is
|
||||
UPSERTed by child (a second set REPLACES the child's existing
|
||||
schedule; the schema's UNIQUE(child_id) makes duplication
|
||||
unrepresentable), then :func:`~.materialize.regenerate_for_child`
|
||||
re-materializes the child's future instances against the new
|
||||
presence. ``today``/``horizon_days`` thread through to that
|
||||
regeneration; raising ValueError names what to fix (an unknown
|
||||
child reads ``child N does not exist``).
|
||||
|
||||
Returns the STORED schedule decoded back through the model — the
|
||||
round-trip proves the encoded pattern the database now holds is
|
||||
exactly the one the caller asked for.
|
||||
"""
|
||||
schedule = PresenceSchedule(
|
||||
child_id, cycle_length_weeks, anchor_date, pattern
|
||||
)
|
||||
record = await PresenceSchedulesDao(database).upsert_by_child(
|
||||
schedule.child_id,
|
||||
schedule.cycle_length_weeks,
|
||||
schedule.anchor_date.isoformat(),
|
||||
schedule.encode(),
|
||||
)
|
||||
await regenerate_for_child(
|
||||
database, schedule.child_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return PresenceSchedule.decode(
|
||||
record.child_id, record.anchor_date, record.pattern
|
||||
)
|
||||
|
||||
|
||||
async def create_presence_override(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
start_date: object,
|
||||
end_date: object,
|
||||
is_present: bool,
|
||||
note: str | None = None,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> PresenceOverrideRecord:
|
||||
"""Create one date-range presence override and return it as stored.
|
||||
|
||||
The :class:`~.presence.PresenceOverride` model validates at
|
||||
construction — strict calendar dates, ``end_date`` on or after
|
||||
``start_date``, a real ``is_present`` bool, a string-or-None note —
|
||||
then the DAO insert applies the same-child no-overlap policy (a
|
||||
conflicting range raises ValueError naming the existing override)
|
||||
and :func:`~.materialize.regenerate_for_child` re-materializes the
|
||||
child's future instances against the override's presence answer.
|
||||
An unknown child reads ``child N does not exist``.
|
||||
|
||||
Returns the stored record WITH its ``id`` — the handle a later
|
||||
:func:`delete_presence_override` takes.
|
||||
"""
|
||||
override = PresenceOverride(
|
||||
child_id, start_date, end_date, is_present, note=note
|
||||
)
|
||||
record = await PresenceOverridesDao(database).create(
|
||||
override.child_id,
|
||||
override.start_date.isoformat(),
|
||||
override.end_date.isoformat(),
|
||||
override.is_present,
|
||||
note=override.note,
|
||||
)
|
||||
await regenerate_for_child(
|
||||
database, override.child_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
async def delete_presence_override(
|
||||
database: NestQuestDatabase,
|
||||
override_id: int,
|
||||
*,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> PresenceOverrideRecord:
|
||||
"""Delete one presence override by id and return it as it was stored.
|
||||
|
||||
The override is looked up FIRST and an unknown id raises ValueError
|
||||
prefixed ``override_id:`` (naming the id) BEFORE any delete, so a
|
||||
mistyped id can never be silently swallowed by a no-op. The
|
||||
deletion is followed by :func:`~.materialize.regenerate_for_child`
|
||||
for the override's own child — the id decides the child, never a
|
||||
caller-supplied one. ``today``/``horizon_days`` thread through to
|
||||
that regeneration.
|
||||
"""
|
||||
_validate_override_id(override_id)
|
||||
dao = PresenceOverridesDao(database)
|
||||
override = await dao.get(override_id)
|
||||
if override is None:
|
||||
raise ValueError(
|
||||
f"override_id: presence override {override_id} does not exist"
|
||||
)
|
||||
await dao.delete(override_id)
|
||||
await regenerate_for_child(
|
||||
database, override.child_id, today=today, horizon_days=horizon_days
|
||||
)
|
||||
return override
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Re-export of :mod:`custom_components.nestquest.core.presence_management` for the integration package.
|
||||
|
||||
The presence-write business layer lives in
|
||||
:mod:`custom_components.nestquest.core.presence_management` (added for
|
||||
task e4a9d31b); this module re-exports it so the integration's service
|
||||
handlers call the ONE implementation the API admin plane shares.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .core.presence_management import * # noqa: F401,F403
|
||||
@@ -4,9 +4,11 @@ Registration is domain-scoped and happens once (see
|
||||
:func:`async_register_services`); handlers resolve a live database at
|
||||
call time through the injected ``find_runtime`` callable. Permission
|
||||
checks are Feature 09 later tasks — this module only validates payload
|
||||
shape and forwards to the Feature 03/06/08 business layers (and the
|
||||
presence DAOs). ``export_history_csv`` is registered with a schema but
|
||||
raises until Feature 13. ``regenerate`` is supplied by the caller so
|
||||
shape and forwards to the Feature 03/06/08 business layers (the
|
||||
presence writes included, through :mod:`.presence_management` — the
|
||||
ONE implementation the API admin plane shares).
|
||||
``export_history_csv`` is registered with a schema but raises until
|
||||
Feature 13. ``regenerate`` is supplied by the caller so
|
||||
the Feature 07 handler stays the one in ``__init__``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +24,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import children as children_layer
|
||||
from . import completion as completion_layer
|
||||
from . import presence_management as presence_management_layer
|
||||
from . import quest_definitions as quest_definitions_layer
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
@@ -39,11 +42,8 @@ from .const import (
|
||||
SERVICE_UNCOMPLETE_QUEST,
|
||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||
)
|
||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
||||
from .events import fire_quest_completed, fire_quest_uncompleted
|
||||
from .materialize import regenerate_for_child
|
||||
from .permissions import permission_gate
|
||||
from .presence import PresenceSchedule
|
||||
from .recurrence import ScheduleRule
|
||||
from .service_policy import SERVICE_POLICY
|
||||
|
||||
@@ -405,21 +405,12 @@ def _set_presence_pattern(
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
schedule = PresenceSchedule(
|
||||
await presence_management_layer.set_presence_schedule(
|
||||
runtime.database,
|
||||
data["child_id"],
|
||||
data["cycle_length_weeks"],
|
||||
data["anchor_date"],
|
||||
data["pattern"],
|
||||
)
|
||||
await PresenceSchedulesDao(runtime.database).upsert_by_child(
|
||||
schedule.child_id,
|
||||
schedule.cycle_length_weeks,
|
||||
schedule.anchor_date.isoformat(),
|
||||
schedule.encode(),
|
||||
)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
schedule.child_id,
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
@@ -432,16 +423,13 @@ def _create_presence_override(
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
data = call.data
|
||||
await PresenceOverridesDao(runtime.database).create(
|
||||
await presence_management_layer.create_presence_override(
|
||||
runtime.database,
|
||||
data["child_id"],
|
||||
data["start_date"],
|
||||
data["end_date"],
|
||||
data["is_present"],
|
||||
note=data.get("note"),
|
||||
)
|
||||
await regenerate_for_child(
|
||||
runtime.database,
|
||||
data["child_id"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
@@ -453,17 +441,9 @@ def _delete_presence_override(
|
||||
) -> ServiceHandler:
|
||||
async def _handler(call: Any) -> None:
|
||||
runtime = _require_runtime(hass, find_runtime)
|
||||
dao = PresenceOverridesDao(runtime.database)
|
||||
override_id = call.data["override_id"]
|
||||
override = await dao.get(override_id)
|
||||
if override is None:
|
||||
raise HomeAssistantError(
|
||||
f"override_id: presence override {override_id} does not exist"
|
||||
)
|
||||
await dao.delete(override_id)
|
||||
await regenerate_for_child(
|
||||
await presence_management_layer.delete_presence_override(
|
||||
runtime.database,
|
||||
override.child_id,
|
||||
call.data["override_id"],
|
||||
**_generation_kwargs(hass, runtime),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
"""Admin-plane presence route tests (task e4a9d31b).
|
||||
|
||||
These tests exercise the admin plane's presence routes against the ASGI
|
||||
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
|
||||
authenticates — signature, issuer, audience, expiry and the
|
||||
nestquest-admins group — with NO network access.
|
||||
|
||||
Every done-condition case is proven against the DATABASE and the DOMAIN,
|
||||
read through the SAME core copy the app uses (``nestquest_core.*``; see
|
||||
the two-copy caveat in api/nestquest_core.py):
|
||||
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an admin JWT passes, a JWT without the group is 403, an
|
||||
absent credential is 401 — and nothing is written.
|
||||
- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the
|
||||
first set persists the schedule, setting again REPLACES it — one
|
||||
schedule row per child, never a duplicate.
|
||||
- ``POST /api/v1/admin/presence-overrides`` creates the override (its
|
||||
``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}``
|
||||
removes it again.
|
||||
- The child's RESOLVED presence changes accordingly: the pure
|
||||
:class:`~nestquest_core.presence.PresenceEngine` built from the
|
||||
stored rows answers differently before/after each write — a schedule
|
||||
restricts the child's present weekdays, an override beats the pattern
|
||||
for its range, and deleting the override restores the pattern's
|
||||
answer.
|
||||
- The regeneration side effect lives in the core layer the route
|
||||
calls: setting an all-absent schedule through the route deletes the
|
||||
child's future open quest instances and they are NOT rebuilt while
|
||||
the child is absent every day — the same proof the HA service test
|
||||
makes, through the API instead.
|
||||
|
||||
Error mapping (documented in api/routes_admin.py): a core ValueError
|
||||
for a non-existent child (schedule or override create) or override
|
||||
(delete) is 404; every other core ValueError — a rejected schedule
|
||||
(cycle length, pattern coverage, anchor date) or override (malformed or
|
||||
inverted dates, an overlapping range) — is 422, the same failure class
|
||||
FastAPI reports for malformed bodies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
# The presence model, DAOs, business layer and materializer through the
|
||||
# SAME core copy the app uses (nestquest_core.*, NOT the conftest-stubbed
|
||||
# custom_components copy) — used for the tests' database and resolved-
|
||||
# presence assertions only; the routes reach them through api.nestquest_core.
|
||||
_children = importlib.import_module("nestquest_core.children")
|
||||
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
_presence = importlib.import_module("nestquest_core.presence")
|
||||
|
||||
#: A Monday anchoring every schedule: cycle week 0 starts here.
|
||||
ANCHOR_MONDAY = "2026-01-05"
|
||||
#: The Tuesday of that week — absent under the test schedule.
|
||||
TUESDAY = "2026-01-06"
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _schedule_body(
|
||||
cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None
|
||||
) -> dict[str, object]:
|
||||
"""A valid schedule body anchored to the test Monday."""
|
||||
return {
|
||||
"cycle_length_weeks": cycle_length_weeks,
|
||||
"anchor_date": ANCHOR_MONDAY,
|
||||
"pattern": {"0": [0, 2, 4]} if pattern is None else pattern,
|
||||
}
|
||||
|
||||
|
||||
def _override_body(
|
||||
child_id: int,
|
||||
*,
|
||||
start_date: str = TUESDAY,
|
||||
end_date: str = TUESDAY,
|
||||
is_present: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""A valid single-day override body for the test child."""
|
||||
return {
|
||||
"child_id": child_id,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"is_present": is_present,
|
||||
"note": "grandmother's week",
|
||||
}
|
||||
|
||||
|
||||
async def _create_child(client: object, name: str = "Ada") -> int:
|
||||
"""Create one child through the admin children route; return the id."""
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
def _engine(
|
||||
database: object,
|
||||
child_id: int,
|
||||
schedule_record: object | None,
|
||||
override_records: list[object],
|
||||
) -> object:
|
||||
"""Build the pure PresenceEngine from stored rows (the snapshot shape).
|
||||
|
||||
Mirrors what the core snapshot builder assembles: the child's
|
||||
decoded schedule (absent → present every day) plus its overrides,
|
||||
so :meth:`PresenceEngine.is_present` answers from the SAME rows the
|
||||
routes produced.
|
||||
"""
|
||||
schedules: dict[int, object] = {}
|
||||
if schedule_record is not None:
|
||||
schedules[child_id] = _presence.PresenceSchedule.decode(
|
||||
schedule_record.child_id,
|
||||
schedule_record.anchor_date,
|
||||
schedule_record.pattern,
|
||||
)
|
||||
overrides = {
|
||||
child_id: [
|
||||
_presence.PresenceOverride(
|
||||
record.child_id,
|
||||
record.start_date,
|
||||
record.end_date,
|
||||
record.is_present,
|
||||
note=record.note,
|
||||
)
|
||||
for record in override_records
|
||||
]
|
||||
}
|
||||
return _presence.PresenceEngine(schedules, overrides)
|
||||
|
||||
|
||||
async def _child_state(
|
||||
database: object, child_id: int
|
||||
) -> tuple[object | None, list[object], object]:
|
||||
"""The child's stored schedule record, overrides and PresenceEngine."""
|
||||
schedule_record = await _dao_presence.PresenceSchedulesDao(
|
||||
database
|
||||
).get_by_child(child_id)
|
||||
override_records = await _dao_presence.PresenceOverridesDao(
|
||||
database
|
||||
).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01")
|
||||
engine = _engine(database, child_id, schedule_record, override_records)
|
||||
return schedule_record, override_records, engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_presence(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database.
|
||||
|
||||
The same runner the auth, children and quest-definition tests use
|
||||
(stubbed JWKS, no network); the fixture also exposes the app's ONE
|
||||
database connection so tests assert the routes' effects on the
|
||||
stored rows and the resolved presence built from them.
|
||||
"""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=runner.app.state.db.database,
|
||||
)
|
||||
|
||||
|
||||
# --- authentication: the ONE require_admin dependency guards every route ---
|
||||
|
||||
|
||||
async def test_presence_routes_refuse_non_admin_and_absent_credentials(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A non-admin JWT is 403, an absent credential is 401: nothing written."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
non_admin_headers = {"Authorization": f"Bearer {non_admin}"}
|
||||
|
||||
forbidden_schedule = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=non_admin_headers,
|
||||
)
|
||||
assert forbidden_schedule.status_code == 403
|
||||
assert forbidden_schedule.json()["detail"] == (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
forbidden_override = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id),
|
||||
headers=non_admin_headers,
|
||||
)
|
||||
assert forbidden_override.status_code == 403
|
||||
|
||||
forbidden_delete = await client.delete(
|
||||
"/api/v1/admin/presence-overrides/1", headers=non_admin_headers
|
||||
)
|
||||
assert forbidden_delete.status_code == 403
|
||||
|
||||
unauthenticated_schedule = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
)
|
||||
assert unauthenticated_schedule.status_code == 401
|
||||
|
||||
unauthenticated_override = await client.post(
|
||||
"/api/v1/admin/presence-overrides", json=_override_body(child_id)
|
||||
)
|
||||
assert unauthenticated_override.status_code == 401
|
||||
|
||||
unauthenticated_delete = await client.delete(
|
||||
"/api/v1/admin/presence-overrides/1"
|
||||
)
|
||||
assert unauthenticated_delete.status_code == 401
|
||||
|
||||
# No route ran its handler: the child still has no schedule and no
|
||||
# overrides (and is present every day, the no-schedule default).
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert _schedule is None
|
||||
assert _overrides == []
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
|
||||
|
||||
# --- schedule upsert --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_schedule_upserts_and_replaces_one_row_per_child(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""PUT /presence-schedule persists, and setting again REPLACES it."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
|
||||
first = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert first.json() == {
|
||||
"child_id": child_id,
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": ANCHOR_MONDAY,
|
||||
"pattern": {"0": [0, 2, 4]},
|
||||
}
|
||||
# Database proof: exactly ONE schedule row (the schema's
|
||||
# UNIQUE(child_id) makes a second row unrepresentable), stored as
|
||||
# encoded CSV.
|
||||
schedule, _overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert schedule is not None
|
||||
row_id = schedule.id
|
||||
assert schedule.cycle_length_weeks == 1
|
||||
assert schedule.anchor_date == ANCHOR_MONDAY
|
||||
assert schedule.pattern == "0,2,4"
|
||||
|
||||
# Setting again UPSERTS: the SAME row is updated in place — new
|
||||
# values, unchanged id — never a duplicate or a stale first set.
|
||||
second = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(
|
||||
cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]}
|
||||
),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json() == {
|
||||
"child_id": child_id,
|
||||
"cycle_length_weeks": 2,
|
||||
"anchor_date": ANCHOR_MONDAY,
|
||||
"pattern": {"0": [], "1": [1, 3]},
|
||||
}
|
||||
schedule, _overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert schedule is not None
|
||||
assert schedule.id == row_id
|
||||
assert schedule.cycle_length_weeks == 2
|
||||
assert schedule.pattern == "|1,3"
|
||||
|
||||
|
||||
async def test_schedule_changes_the_childs_resolved_presence(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""The resolved presence (PresenceEngine) follows the stored schedule."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
|
||||
# Before any schedule the child is present every day.
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
assert engine.is_present(child_id, TUESDAY) is True
|
||||
|
||||
# Mon/Wed/Fri only, week 0 of a one-week cycle.
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
_schedule, _overrides, engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
assert engine.is_present(child_id, TUESDAY) is False
|
||||
|
||||
|
||||
async def test_schedule_upsert_regenerates_future_instances(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Setting an all-absent schedule deletes the child's future instances.
|
||||
|
||||
The regeneration side effect lives in the core layer the route
|
||||
calls: the child's open instances at or after today are deleted
|
||||
across ALL its definitions and NOT rebuilt while the child is
|
||||
absent every day — the same proof the HA service test makes,
|
||||
exercised here through the API route instead.
|
||||
"""
|
||||
client = admin_presence.client
|
||||
database = admin_presence.database
|
||||
child_id = await _create_child(client)
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
today = datetime.date.today()
|
||||
start = today.isoformat()
|
||||
end = (today + datetime.timedelta(days=14)).isoformat()
|
||||
await _materialize.materialize(database, start, end, today=today)
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
assert await instances.list_by_date_range(child_id, start, end)
|
||||
|
||||
# The route's write must regenerate: absent every day removes every
|
||||
# open future instance.
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(pattern={"0": []}),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert await instances.list_by_date_range(child_id, start, end) == []
|
||||
|
||||
|
||||
# --- override create + delete -----------------------------------------------
|
||||
|
||||
|
||||
async def test_override_is_created_then_deleted_and_presence_resolves(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""POST creates the override, DELETE removes it; presence follows."""
|
||||
client = admin_presence.client
|
||||
database = admin_presence.database
|
||||
child_id = await _create_child(client)
|
||||
schedule_response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert schedule_response.status_code == 200
|
||||
|
||||
# Create: the override comes back with its id and the posted shape.
|
||||
created = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
payload = created.json()
|
||||
assert set(payload) == {
|
||||
"id",
|
||||
"child_id",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"is_present",
|
||||
"note",
|
||||
}
|
||||
assert payload["child_id"] == child_id
|
||||
assert payload["start_date"] == TUESDAY
|
||||
assert payload["end_date"] == TUESDAY
|
||||
assert payload["is_present"] is True
|
||||
assert payload["note"] == "grandmother's week"
|
||||
|
||||
# Database proof: the row exists — and the RESOLVED presence for the
|
||||
# affected date flips: the override beats the Tuesday-absent pattern.
|
||||
_schedule, overrides, engine = await _child_state(database, child_id)
|
||||
assert [record.id for record in overrides] == [payload["id"]]
|
||||
assert engine.is_present(child_id, TUESDAY) is True
|
||||
# A date the override does not cover keeps the pattern's answer.
|
||||
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
|
||||
|
||||
# Delete: the row is gone and the resolved presence follows the
|
||||
# pattern again.
|
||||
deleted = await client.delete(
|
||||
f"/api/v1/admin/presence-overrides/{payload['id']}",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json() == {"status": "ok"}
|
||||
assert (
|
||||
await _dao_presence.PresenceOverridesDao(database).get(payload["id"])
|
||||
is None
|
||||
)
|
||||
_schedule, overrides, engine = await _child_state(database, child_id)
|
||||
assert overrides == []
|
||||
assert engine.is_present(child_id, TUESDAY) is False
|
||||
|
||||
|
||||
# --- error mapping: 404 vs 422 ----------------------------------------------
|
||||
|
||||
|
||||
async def test_schedule_and_override_for_unknown_child_are_404(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A presence write naming a child that does not exist is 404."""
|
||||
client = admin_presence.client
|
||||
|
||||
schedule = await client.put(
|
||||
"/api/v1/admin/children/999/presence-schedule",
|
||||
json=_schedule_body(),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert schedule.status_code == 404
|
||||
assert schedule.json()["detail"] == "Child not found"
|
||||
|
||||
override = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(999),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert override.status_code == 404
|
||||
assert override.json()["detail"] == "Child not found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
_schedule_body(cycle_length_weeks=0), # below the 1..4 range
|
||||
_schedule_body(cycle_length_weeks=5), # above the documented cap
|
||||
# week 1 of the cycle missing:
|
||||
_schedule_body(cycle_length_weeks=2, pattern={"0": [0]}),
|
||||
{
|
||||
"cycle_length_weeks": 1,
|
||||
"anchor_date": "2026-13-40",
|
||||
"pattern": {"0": [0]},
|
||||
},
|
||||
_schedule_body(pattern={"0": [7]}), # weekday out of 0..6
|
||||
_schedule_body(pattern={"nope": [0]}), # week index not an integer
|
||||
_schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list
|
||||
],
|
||||
)
|
||||
async def test_rejected_schedule_is_422(
|
||||
admin_presence: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""A rejected schedule is 422 and stores nothing."""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
response = await client.put(
|
||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
||||
json=body,
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert (
|
||||
await _dao_presence.PresenceSchedulesDao(
|
||||
admin_presence.database
|
||||
).get_by_child(child_id)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
_override_body(1, start_date="2026-01-07", end_date="2026-01-06"),
|
||||
_override_body(1, start_date="2026-02-30", end_date="2026-02-30"),
|
||||
dict(_override_body(1), note=5),
|
||||
dict(_override_body(1), child_id="one"),
|
||||
],
|
||||
)
|
||||
async def test_rejected_override_is_422(
|
||||
admin_presence: SimpleNamespace, body: dict[str, object]
|
||||
) -> None:
|
||||
"""A rejected override (inverted/malformed dates, bad note/id) is 422.
|
||||
|
||||
The shape failures (a non-string note, a non-integer child id) are
|
||||
the body model's; the date failures are the PresenceOverride
|
||||
model's. A string ``is_present`` like ``"yes"`` is deliberately
|
||||
absent: Pydantic's lax bool COERCES it to ``True`` (the same design
|
||||
as the children active route), so it never reaches the model's
|
||||
real-bool check.
|
||||
"""
|
||||
response = await admin_presence.client.post(
|
||||
"/api/v1/admin/presence-overrides", json=body, headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_overlapping_override_is_422_and_first_survives(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An override overlapping the child's existing one is 422.
|
||||
|
||||
The conflicting write persists nothing: the child's original
|
||||
override survives unchanged.
|
||||
"""
|
||||
client = admin_presence.client
|
||||
child_id = await _create_child(client)
|
||||
created = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/presence-overrides",
|
||||
json=_override_body(child_id, is_present=False),
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
_schedule, overrides, _engine = await _child_state(
|
||||
admin_presence.database, child_id
|
||||
)
|
||||
assert [record.id for record in overrides] == [created.json()["id"]]
|
||||
|
||||
|
||||
async def test_delete_unknown_override_is_404(
|
||||
admin_presence: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Deleting an override id that does not exist is 404."""
|
||||
response = await admin_presence.client.delete(
|
||||
"/api/v1/admin/presence-overrides/999", headers=_admin_headers()
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Presence override not found"
|
||||
|
||||
malformed = await admin_presence.client.delete(
|
||||
"/api/v1/admin/presence-overrides/not-an-id",
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert malformed.status_code == 422
|
||||
Reference in New Issue
Block a user