Admin routes: presence schedule and override (#160)
This commit was merged in pull request #160.
This commit is contained in:
@@ -142,13 +142,35 @@ core_quest_definitions = importlib.import_module(
|
|||||||
#: re-implement rule validation.
|
#: re-implement rule validation.
|
||||||
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
|
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__ = [
|
__all__ = [
|
||||||
"core",
|
"core",
|
||||||
"core_children",
|
"core_children",
|
||||||
"core_completion",
|
"core_completion",
|
||||||
|
"core_dao_presence",
|
||||||
"core_db",
|
"core_db",
|
||||||
"core_events",
|
"core_events",
|
||||||
"core_migrations",
|
"core_migrations",
|
||||||
|
"core_presence",
|
||||||
|
"core_presence_management",
|
||||||
"core_quest_definitions",
|
"core_quest_definitions",
|
||||||
"core_recurrence",
|
"core_recurrence",
|
||||||
"core_snapshot",
|
"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
|
route forwards only SUPPLIED fields (``exclude_unset``), like the
|
||||||
children edit.
|
children edit.
|
||||||
|
|
||||||
Error mapping (every children and quest-definition route, deliberately
|
The presence routes follow the same pattern over
|
||||||
narrow):
|
: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
|
- Malformed input the body model or the path parser rejects (a missing
|
||||||
or wrong-typed field, a non-integer child or definition id) is
|
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
|
``rule_type``, a shape-missing field such as a weekly rule without
|
||||||
weekdays, a malformed date, a forbidden field), a bad window name or
|
weekdays, a malformed date, a forbidden field), a bad window name or
|
||||||
due time, a rejected window or assignee list (empty, duplicate,
|
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
|
semantically invalid, the same failure class Pydantic reports. The
|
||||||
core rejects these BEFORE any write, so a 422 never leaves a
|
core rejects these BEFORE any write, so a 422 never leaves a
|
||||||
half-applied change behind.
|
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
|
PATCH edit semantics: the body model's fields are optional and only
|
||||||
SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so
|
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.database import DatabaseState
|
||||||
from api.nestquest_core import (
|
from api.nestquest_core import (
|
||||||
core_children,
|
core_children,
|
||||||
|
core_presence,
|
||||||
|
core_presence_management,
|
||||||
core_quest_definitions,
|
core_quest_definitions,
|
||||||
core_recurrence,
|
core_recurrence,
|
||||||
)
|
)
|
||||||
@@ -294,6 +331,70 @@ class AdminQuestDefinitionActiveRequest(BaseModel):
|
|||||||
is_active: bool
|
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.
|
#: 404 detail for a child id the core layer reports as non-existent.
|
||||||
_CHILD_NOT_FOUND_DETAIL = "Child not found"
|
_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
|
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:
|
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
|
||||||
"""Build the core ScheduleRule from the request's ``rule`` object.
|
"""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")
|
@router.get("/ping", summary="Admin plane probe")
|
||||||
async def admin_ping(
|
async def admin_ping(
|
||||||
claims: Annotated[dict[str, object], Depends(require_admin)],
|
claims: Annotated[dict[str, object], Depends(require_admin)],
|
||||||
@@ -681,4 +832,114 @@ async def admin_set_quest_definition_active(
|
|||||||
return _quest_definition_response(bundle)
|
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"]
|
__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
|
:func:`async_register_services`); handlers resolve a live database at
|
||||||
call time through the injected ``find_runtime`` callable. Permission
|
call time through the injected ``find_runtime`` callable. Permission
|
||||||
checks are Feature 09 later tasks — this module only validates payload
|
checks are Feature 09 later tasks — this module only validates payload
|
||||||
shape and forwards to the Feature 03/06/08 business layers (and the
|
shape and forwards to the Feature 03/06/08 business layers (the
|
||||||
presence DAOs). ``export_history_csv`` is registered with a schema but
|
presence writes included, through :mod:`.presence_management` — the
|
||||||
raises until Feature 13. ``regenerate`` is supplied by the caller so
|
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__``.
|
the Feature 07 handler stays the one in ``__init__``.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,6 +24,7 @@ from homeassistant.exceptions import HomeAssistantError
|
|||||||
|
|
||||||
from . import children as children_layer
|
from . import children as children_layer
|
||||||
from . import completion as completion_layer
|
from . import completion as completion_layer
|
||||||
|
from . import presence_management as presence_management_layer
|
||||||
from . import quest_definitions as quest_definitions_layer
|
from . import quest_definitions as quest_definitions_layer
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -39,11 +42,8 @@ from .const import (
|
|||||||
SERVICE_UNCOMPLETE_QUEST,
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||||
)
|
)
|
||||||
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
|
|
||||||
from .events import fire_quest_completed, fire_quest_uncompleted
|
from .events import fire_quest_completed, fire_quest_uncompleted
|
||||||
from .materialize import regenerate_for_child
|
|
||||||
from .permissions import permission_gate
|
from .permissions import permission_gate
|
||||||
from .presence import PresenceSchedule
|
|
||||||
from .recurrence import ScheduleRule
|
from .recurrence import ScheduleRule
|
||||||
from .service_policy import SERVICE_POLICY
|
from .service_policy import SERVICE_POLICY
|
||||||
|
|
||||||
@@ -405,21 +405,12 @@ def _set_presence_pattern(
|
|||||||
async def _handler(call: Any) -> None:
|
async def _handler(call: Any) -> None:
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
runtime = _require_runtime(hass, find_runtime)
|
||||||
data = call.data
|
data = call.data
|
||||||
schedule = PresenceSchedule(
|
await presence_management_layer.set_presence_schedule(
|
||||||
|
runtime.database,
|
||||||
data["child_id"],
|
data["child_id"],
|
||||||
data["cycle_length_weeks"],
|
data["cycle_length_weeks"],
|
||||||
data["anchor_date"],
|
data["anchor_date"],
|
||||||
data["pattern"],
|
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),
|
**_generation_kwargs(hass, runtime),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -432,16 +423,13 @@ def _create_presence_override(
|
|||||||
async def _handler(call: Any) -> None:
|
async def _handler(call: Any) -> None:
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
runtime = _require_runtime(hass, find_runtime)
|
||||||
data = call.data
|
data = call.data
|
||||||
await PresenceOverridesDao(runtime.database).create(
|
await presence_management_layer.create_presence_override(
|
||||||
|
runtime.database,
|
||||||
data["child_id"],
|
data["child_id"],
|
||||||
data["start_date"],
|
data["start_date"],
|
||||||
data["end_date"],
|
data["end_date"],
|
||||||
data["is_present"],
|
data["is_present"],
|
||||||
note=data.get("note"),
|
note=data.get("note"),
|
||||||
)
|
|
||||||
await regenerate_for_child(
|
|
||||||
runtime.database,
|
|
||||||
data["child_id"],
|
|
||||||
**_generation_kwargs(hass, runtime),
|
**_generation_kwargs(hass, runtime),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -453,17 +441,9 @@ def _delete_presence_override(
|
|||||||
) -> ServiceHandler:
|
) -> ServiceHandler:
|
||||||
async def _handler(call: Any) -> None:
|
async def _handler(call: Any) -> None:
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
runtime = _require_runtime(hass, find_runtime)
|
||||||
dao = PresenceOverridesDao(runtime.database)
|
await presence_management_layer.delete_presence_override(
|
||||||
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(
|
|
||||||
runtime.database,
|
runtime.database,
|
||||||
override.child_id,
|
call.data["override_id"],
|
||||||
**_generation_kwargs(hass, runtime),
|
**_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