Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
212 lines
8.8 KiB
Python
212 lines
8.8 KiB
Python
"""Transition-event payload construction for the NestQuest service paths (Feature 10).
|
|
|
|
This module is PAYLOAD CONSTRUCTION ONLY: it builds and RETURNS the
|
|
transition events (event type + payload dict) and never touches the
|
|
Home Assistant event bus or config. The caller decides how to fire
|
|
them (the integration's re-export shim wires the returned events to
|
|
``hass.bus.async_fire``).
|
|
|
|
The four documented events (design/ENTITIES-AND-SERVICES.md §3, names
|
|
in :mod:`~.const`): ``nestquest_quest_completed`` and
|
|
``nestquest_quest_uncompleted`` fire from the completion service
|
|
handlers on an actual transition (a no-op re-complete or re-uncomplete
|
|
fires nothing), and ``nestquest_child_day_complete`` fires when a
|
|
completion clears the child's whole day (quests were owed and none
|
|
remain — a zero-quest day can never fire it, matching the all-done
|
|
binary sensor's rule). ``nestquest_quest_missed`` is the documented
|
|
contract Feature 11's nightly sweep fires; :func:`build_quest_missed_event`
|
|
builds it with exactly the payload the sweep hand-builds (§3), so the
|
|
API plane can publish the same shape without re-deriving it.
|
|
|
|
The completed-event and day-complete-event builders are split so the
|
|
shim can fire ``nestquest_quest_completed`` BEFORE it evaluates the
|
|
day-complete rule (the pre-extraction implementation fired the
|
|
completed event on the bus first, then ran the day-complete DB reads;
|
|
a failure in those reads or the ZoneInfo lookup must not suppress the
|
|
completed event). :func:`build_quest_completed_event` builds the
|
|
completed event from the instance and its child/definition only;
|
|
:func:`build_child_day_complete_event` runs the day-complete DB reads
|
|
and returns the day-complete event (or ``None``). The shim fires the
|
|
completed event, then calls the day-complete builder and fires its
|
|
result. :func:`build_quest_uncompleted_events` builds the single
|
|
uncompleted event.
|
|
|
|
Payload policy: one dict carrying ``child_id``, ``child_name``,
|
|
``instance_id``, ``quest_title``, the instance's ``window``/
|
|
``due_date``/``due_time`` so automation authors can filter without a
|
|
database lookup, and ``occurred_at`` — the event's own strict UTC
|
|
ISO-8601 timestamp. Completion payloads additionally carry
|
|
``was_on_time``. No other personal data: names and titles are the
|
|
household's own records, and nothing beyond them leaks.
|
|
|
|
Timezone policy: the day-complete check needs the household-local
|
|
"today", which the caller passes in as a plain :class:`datetime.date`.
|
|
Nothing here reads a timezone — the API owns the clock (the
|
|
admin-stored application timezone, or the API host's local zone when
|
|
none is stored), the core owns the calendar arithmetic.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
|
|
from .completion import derive_state
|
|
from .const import (
|
|
EVENT_CHILD_DAY_COMPLETE,
|
|
EVENT_QUEST_COMPLETED,
|
|
EVENT_QUEST_MISSED,
|
|
EVENT_QUEST_UNCOMPLETED,
|
|
)
|
|
from .dao_children import ChildrenDao
|
|
from .dao_instances import CompletionEventsDao, QuestInstancesDao
|
|
from .dao_rules import QuestDefinitionsDao
|
|
from .db import NestQuestDatabase
|
|
|
|
#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape,
|
|
#: explicit offset, second precision.
|
|
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
|
|
|
|
|
|
def _now_stamp() -> str:
|
|
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
|
_UTC_TIMESTAMP_FORMAT
|
|
)
|
|
|
|
|
|
async def _instance_payload(
|
|
database: NestQuestDatabase, instance_id: int, now_stamp: str
|
|
) -> dict:
|
|
"""Build the shared event payload for one quest instance."""
|
|
instance = await QuestInstancesDao(database).get_by_id(instance_id)
|
|
if instance is None:
|
|
raise ValueError(
|
|
f"instance_id: quest instance {instance_id} does not exist"
|
|
)
|
|
child = await ChildrenDao(database).get(instance.child_id)
|
|
definition = await QuestDefinitionsDao(database).get(
|
|
instance.definition_id
|
|
)
|
|
return {
|
|
"child_id": instance.child_id,
|
|
"child_name": child.display_name if child else None,
|
|
"instance_id": instance.id,
|
|
"quest_title": definition.title if definition else None,
|
|
"window": instance.window,
|
|
"due_date": instance.due_date,
|
|
"due_time": instance.due_time,
|
|
"occurred_at": now_stamp,
|
|
}
|
|
|
|
|
|
async def build_quest_completed_event(
|
|
database: NestQuestDatabase,
|
|
instance_id: int,
|
|
*,
|
|
was_on_time: bool | None,
|
|
) -> tuple[str, dict]:
|
|
"""Build the ``nestquest_quest_completed`` event for an actual
|
|
completion.
|
|
|
|
Called by the complete_quest service handler AFTER an actual
|
|
completion transition, with the completion's ``was_on_time`` —
|
|
computed under the completion lock and carried on the result — so
|
|
the payload never re-reads mutable latest state a racing reversal
|
|
could have replaced. A no-op re-complete never reaches here.
|
|
|
|
Returns the ``(event_type, payload)`` tuple to fire. This builder
|
|
touches NO day-complete state: it only reads the instance, its
|
|
child, and its definition, so a failure in the day-complete
|
|
evaluation (DB reads, timezone lookup in the shim) cannot suppress
|
|
the completed event. The caller fires this BEFORE asking
|
|
:func:`build_child_day_complete_event` for the day-complete event.
|
|
"""
|
|
now_stamp = _now_stamp()
|
|
payload = await _instance_payload(database, instance_id, now_stamp)
|
|
payload["was_on_time"] = was_on_time
|
|
return (EVENT_QUEST_COMPLETED, payload)
|
|
|
|
|
|
async def build_child_day_complete_event(
|
|
database: NestQuestDatabase,
|
|
completed_payload: dict,
|
|
*,
|
|
today: datetime.date,
|
|
) -> tuple[str, dict] | None:
|
|
"""Build the ``nestquest_child_day_complete`` event for a
|
|
completion that cleared the child's whole day, or ``None`` when no
|
|
day-complete event should fire.
|
|
|
|
Called by the complete_quest service handler AFTER the
|
|
``nestquest_quest_completed`` event has already been fired, with
|
|
that event's payload (so this builder reuses the same
|
|
``occurred_at`` stamp and the same ``child_id`` / ``child_name``
|
|
the completed event carried — one transition, one shared
|
|
timestamp, matching the pre-extraction implementation).
|
|
|
|
Day-complete rule (identical to the pre-extraction implementation
|
|
and to the all-done binary sensor): quests were owed today and
|
|
none remain — a zero-quest day can never fire it. Only a
|
|
completion of TODAY'S instance evaluates it: completing a
|
|
back-dated or future instance must not announce a cleared day.
|
|
|
|
``today`` is the household-local calendar date the day-complete
|
|
rule is evaluated against; the API resolves it in the application
|
|
timezone and passes it in, so this module never reads a clock or a
|
|
timezone of its own.
|
|
"""
|
|
# Only a completion of TODAY'S instance evaluates the day-complete
|
|
# rule: completing a back-dated or future instance must not
|
|
# announce a cleared day.
|
|
if completed_payload["due_date"] != today.isoformat():
|
|
return None
|
|
instances = await QuestInstancesDao(database).list_by_date_range(
|
|
completed_payload["child_id"], today.isoformat(), today.isoformat()
|
|
)
|
|
completion_events = CompletionEventsDao(database)
|
|
remaining = 0
|
|
for instance in instances:
|
|
latest_for_instance = await completion_events.get_latest_for_instance(
|
|
instance.id
|
|
)
|
|
if derive_state(instance, latest_for_instance, today) != "done":
|
|
remaining += 1
|
|
if not instances or remaining:
|
|
return None
|
|
return (
|
|
EVENT_CHILD_DAY_COMPLETE,
|
|
{
|
|
"child_id": completed_payload["child_id"],
|
|
"child_name": completed_payload["child_name"],
|
|
"quests_due": len(instances),
|
|
"quests_completed": len(instances),
|
|
"occurred_at": completed_payload["occurred_at"],
|
|
},
|
|
)
|
|
|
|
|
|
async def build_quest_uncompleted_events(
|
|
database: NestQuestDatabase, instance_id: int
|
|
) -> list[tuple[str, dict]]:
|
|
"""Build the ``nestquest_quest_uncompleted`` event for an actual reversal.
|
|
|
|
Returns a one-element list of ``(event_type, payload)`` to fire.
|
|
"""
|
|
payload = await _instance_payload(database, instance_id, _now_stamp())
|
|
return [(EVENT_QUEST_UNCOMPLETED, payload)]
|
|
|
|
|
|
async def build_quest_missed_event(
|
|
database: NestQuestDatabase, instance_id: int
|
|
) -> tuple[str, dict]:
|
|
"""Build the ``nestquest_quest_missed`` event for one instance.
|
|
|
|
The payload mirrors the nightly sweep's hand-built payload exactly
|
|
(the shared :func:`_instance_payload` shape: ``child_id``,
|
|
``child_name``, ``instance_id``, ``quest_title``, ``window``,
|
|
``due_date``, ``due_time``, ``occurred_at``), so the API plane's
|
|
missed path publishes the same event shape the integration's sweep
|
|
fires. Like the sweep, this builder never mutates the instance or
|
|
writes a completion event — it reads only.
|
|
"""
|
|
payload = await _instance_payload(database, instance_id, _now_stamp())
|
|
return (EVENT_QUEST_MISSED, payload)
|