Add actor_child_id to completion_events (D-008)
Per IMPLEMENTATION-ORDER.md 1.5: a nullable actor_child_id column records which panel profile was tapped; admin events stay NULL. - schema.py: completion_events table body extracted to COMPLETION_EVENTS_TABLE_SQL; the new column carries a FK to children(id) and the actor-pair coherence is extended to a second CHECK: 'user' events require actor_user_id and forbid actor_child_id, 'panel' events require actor_child_id and forbid actor_user_id. - migrations.py: migration 6 rebuilds legacy-shaped completion_events via the canonical procedure (CHECKs cannot be added to an existing table), backfilling each legacy panel row to its own child_id (pre-D-008 panel completions were always the tapped child) via INSERT..SELECT — no UPDATE or DELETE path exists, so the append-only guarantee is untouched; ids preserved; PRAGMA foreign_key_check verified in-transaction. - dao_instances.py: append() takes actor_child_id, validates both actor shapes (panel requires it, user forbids it) and the tapped child's existence under the connection lock; the record and column list grow accordingly. - tests: coherence CHECK matrix (5 bad shapes), unknown actor_child_id FK, panel/user round-trips at DDL and DAO layers, migration-6 backfill test on a frozen v2-shape seed; the completion_events leak guard gains justified INSERT exemptions for the migration layer (UPDATE/DELETE stay forbidden everywhere).
This commit is contained in:
@@ -106,6 +106,7 @@ class CompletionEventRecord:
|
||||
event_type: str
|
||||
actor_source: str
|
||||
actor_user_id: str | None
|
||||
actor_child_id: int | None
|
||||
occurred_at: str
|
||||
was_on_time: bool | None
|
||||
|
||||
@@ -115,7 +116,7 @@ _INSTANCE_COLUMNS = (
|
||||
)
|
||||
_EVENT_COLUMNS = (
|
||||
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
|
||||
"occurred_at, was_on_time"
|
||||
"actor_child_id, occurred_at, was_on_time"
|
||||
)
|
||||
|
||||
#: The only event types the append path accepts; the schema CHECK is
|
||||
@@ -144,8 +145,9 @@ def _event_from_row(row: tuple) -> CompletionEventRecord:
|
||||
event_type=row[3],
|
||||
actor_source=row[4],
|
||||
actor_user_id=row[5],
|
||||
occurred_at=row[6],
|
||||
was_on_time=None if row[7] is None else bool(row[7]),
|
||||
actor_child_id=row[6],
|
||||
occurred_at=row[7],
|
||||
was_on_time=None if row[8] is None else bool(row[8]),
|
||||
)
|
||||
|
||||
|
||||
@@ -396,19 +398,21 @@ class CompletionEventsDao:
|
||||
was_on_time: bool | None,
|
||||
*,
|
||||
actor_user_id: str | None = None,
|
||||
actor_child_id: int | None = None,
|
||||
) -> CompletionEventRecord:
|
||||
"""Append one completion or un-completion event.
|
||||
|
||||
``actor_source`` is 'user' (with ``actor_user_id`` set) or
|
||||
'panel' (no user id — the tapped child is ``child_id``); the
|
||||
actor-pair CHECK enforces this shape at the schema level.
|
||||
``occurred_at`` must be the module's one strict UTC shape
|
||||
(YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range queries
|
||||
stay exact. ``was_on_time`` is required (True/False) for
|
||||
completions; for un-completions it may be None or the reversed
|
||||
completion's flag (Feature 08 still deciding). The instance
|
||||
and child must exist — validated atomically under the
|
||||
connection lock.
|
||||
``actor_source`` is 'user' (an admin action: ``actor_user_id``
|
||||
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
|
||||
id, and ``actor_child_id`` carrying the tapped child profile,
|
||||
D-008); the actor-pair CHECKs enforce this shape at the schema
|
||||
level. ``occurred_at`` must be the module's one strict UTC
|
||||
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
|
||||
queries stay exact. ``was_on_time`` is required (True/False)
|
||||
for completions; for un-completions it may be None or the
|
||||
reversed completion's flag (Feature 08 still deciding). The
|
||||
instance, child and — for panel events — the tapped child must
|
||||
exist — validated atomically under the connection lock.
|
||||
"""
|
||||
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
|
||||
raise ValueError(
|
||||
@@ -428,6 +432,15 @@ class CompletionEventsDao:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' must not carry actor_user_id"
|
||||
)
|
||||
if actor_source == "panel" and actor_child_id is None:
|
||||
raise ValueError(
|
||||
"actor_source 'panel' requires actor_child_id "
|
||||
"(the tapped child profile)"
|
||||
)
|
||||
if actor_source == "user" and actor_child_id is not None:
|
||||
raise ValueError(
|
||||
"actor_source 'user' must not carry actor_child_id"
|
||||
)
|
||||
if was_on_time is not None and type(was_on_time) is not bool:
|
||||
raise ValueError(
|
||||
f"was_on_time must be True, False or None, got "
|
||||
@@ -458,16 +471,28 @@ class CompletionEventsDao:
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
if actor_child_id is not None:
|
||||
tapped = await self._database.fetch_one(
|
||||
"SELECT 1 FROM children WHERE id = ?",
|
||||
(actor_child_id,),
|
||||
)
|
||||
if tapped is None:
|
||||
raise ValueError(
|
||||
f"actor_child_id {actor_child_id} does not "
|
||||
"reference an existing child"
|
||||
)
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO completion_events (instance_id, child_id, "
|
||||
"event_type, actor_source, actor_user_id, occurred_at, "
|
||||
"was_on_time) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"event_type, actor_source, actor_user_id, "
|
||||
"actor_child_id, occurred_at, was_on_time) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
instance_id,
|
||||
child_id,
|
||||
event_type,
|
||||
actor_source,
|
||||
actor_user_id,
|
||||
actor_child_id,
|
||||
occurred_at,
|
||||
None if was_on_time is None else int(was_on_time),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user