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),
|
||||
),
|
||||
|
||||
@@ -25,11 +25,12 @@ pre-D-008 databases to the multi-assignee model: it creates
|
||||
``quest_definition_assignees`` and, when ``quest_definitions`` still
|
||||
carries a single ``child_id`` column, rebuilds the table without it,
|
||||
copying each definition's assignee across. Migration 4 adds the
|
||||
windows table and migration 5 rebuilds ``quest_instances`` onto the
|
||||
widened (definition_id, child_id, due_date, window) key. Each callable
|
||||
migration runs inside its own transaction together with its version
|
||||
stamp, so a crash mid-step rolls the statements and the stamp back
|
||||
together.
|
||||
windows table, migration 5 rebuilds ``quest_instances`` onto the
|
||||
widened (definition_id, child_id, due_date, window) key, and migration
|
||||
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
|
||||
column. Each callable migration runs inside its own transaction
|
||||
together with its version stamp, so a crash mid-step rolls the
|
||||
statements and the stamp back together.
|
||||
|
||||
Migration entries are either an ordered sequence of SQL statements or
|
||||
an async callable taking ``(database, target_version)``. Callables
|
||||
@@ -73,6 +74,7 @@ from typing import Union
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
from .schema import (
|
||||
COMPLETION_EVENTS_TABLE_SQL,
|
||||
QUEST_DEFINITIONS_TABLE_SQL,
|
||||
QUEST_INSTANCES_TABLE_SQL,
|
||||
SCHEMA_V1_STATEMENTS,
|
||||
@@ -326,6 +328,74 @@ async def _widen_instance_key(
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _add_actor_child_id(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Bring ``completion_events`` to the D-008 actor-child shape.
|
||||
|
||||
The ``actor_child_id`` column records which panel profile was
|
||||
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
|
||||
because the canonical shape also carries an actor-pair coherence
|
||||
CHECK (panel events require it, user events forbid it), and CHECK
|
||||
constraints cannot be added to an existing table. Databases with
|
||||
the legacy shape are therefore rebuilt via SQLite's canonical
|
||||
procedure, backfilling each legacy panel row's ``actor_child_id``
|
||||
to ``child_id`` — pre-D-008 panel completions were always recorded
|
||||
against the tapped child themselves — while admin rows stay NULL.
|
||||
Row ids are preserved so ``quest_instances`` references and the
|
||||
append-only history stay intact. Fresh databases already match
|
||||
the canonical shape and only stamp.
|
||||
|
||||
The rebuild runs with ``foreign_keys = OFF`` and verifies
|
||||
``PRAGMA foreign_key_check`` inside the transaction before the
|
||||
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
|
||||
completion_events table keeps its append-only guarantee.
|
||||
"""
|
||||
legacy_shape = not await _table_has_column(
|
||||
database, "completion_events", "actor_child_id"
|
||||
)
|
||||
await database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
async with database.transaction():
|
||||
if legacy_shape:
|
||||
await database.execute(
|
||||
"CREATE TABLE completion_events_rebuilt "
|
||||
f"{COMPLETION_EVENTS_TABLE_SQL}"
|
||||
)
|
||||
await database.execute(
|
||||
"INSERT INTO completion_events_rebuilt "
|
||||
"(id, instance_id, child_id, event_type, actor_source, "
|
||||
"actor_user_id, actor_child_id, occurred_at, "
|
||||
"was_on_time) "
|
||||
"SELECT id, instance_id, child_id, event_type, "
|
||||
"actor_source, actor_user_id, "
|
||||
"CASE WHEN actor_source = 'panel' THEN child_id "
|
||||
"ELSE NULL END, "
|
||||
"occurred_at, was_on_time "
|
||||
"FROM completion_events ORDER BY id"
|
||||
)
|
||||
await database.execute("DROP TABLE completion_events")
|
||||
await database.execute(
|
||||
"ALTER TABLE completion_events_rebuilt "
|
||||
"RENAME TO completion_events"
|
||||
)
|
||||
violations = await database.fetch_all(
|
||||
"PRAGMA foreign_key_check"
|
||||
)
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
"completion_events rebuild produced foreign-key "
|
||||
f"violations: {violations!r}"
|
||||
)
|
||||
LOGGER.info(
|
||||
"Added actor_child_id to completion_events; legacy "
|
||||
"panel rows backfilled to their own child_id"
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
finally:
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
#: The ordered migration list. Append-only: never edit an applied
|
||||
#: entry, add the next one instead. (Migration 1's content was
|
||||
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
||||
@@ -340,6 +410,10 @@ MIGRATIONS: Sequence[MigrationStep] = [
|
||||
# Migration 5 (D-008): rebuild quest_instances onto the widened
|
||||
# (definition_id, child_id, due_date, window) key.
|
||||
_widen_instance_key,
|
||||
# Migration 6 (D-008): rebuild completion_events with the
|
||||
# actor_child_id column and its actor-pair coherence CHECK,
|
||||
# backfilling legacy panel rows to their own child_id.
|
||||
_add_actor_child_id,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -159,11 +159,12 @@ self-describing without joins.
|
||||
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
||||
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
||||
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
||||
NULL — the tapped child profile is already ``child_id``). A coherence
|
||||
CHECK makes the pair shape non-negotiable: ``'user'`` requires
|
||||
``actor_user_id``, ``'panel'`` forbids it. Other actor sources are
|
||||
rejected; if an unattended system actor is ever needed, that is a
|
||||
schema migration, not a silent widening.
|
||||
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
|
||||
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
|
||||
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
|
||||
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
|
||||
Other actor sources are rejected; if an unattended system actor is
|
||||
ever needed, that is a schema migration, not a silent widening.
|
||||
|
||||
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
||||
``completed`` events — at completion time the due date is known and the
|
||||
@@ -344,29 +345,42 @@ SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS completion_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
||||
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
||||
actor_user_id TEXT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
was_on_time INTEGER CHECK (
|
||||
was_on_time IS NULL
|
||||
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
||||
),
|
||||
CHECK (
|
||||
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
||||
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
||||
),
|
||||
CHECK (
|
||||
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
||||
OR (event_type = 'uncompleted')
|
||||
)
|
||||
#: Column body of ``completion_events``, shared between the v1 DDL
|
||||
#: below and the actor-child rebuild migration, which must recreate the
|
||||
#: table in exactly this shape (D-008: ``actor_child_id`` records which
|
||||
#: panel profile was tapped — set for panel events, NULL for admin
|
||||
#: ones; legacy panel rows backfill to ``child_id``, the child they
|
||||
#: always were).
|
||||
COMPLETION_EVENTS_TABLE_SQL = """(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
||||
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
||||
actor_user_id TEXT,
|
||||
actor_child_id INTEGER REFERENCES children(id),
|
||||
occurred_at TEXT NOT NULL,
|
||||
was_on_time INTEGER CHECK (
|
||||
was_on_time IS NULL
|
||||
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
||||
),
|
||||
CHECK (
|
||||
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
||||
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
||||
),
|
||||
CHECK (
|
||||
(actor_source = 'user' AND actor_child_id IS NULL)
|
||||
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
|
||||
),
|
||||
CHECK (
|
||||
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
||||
OR (event_type = 'uncompleted')
|
||||
)
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
+36
-10
@@ -664,6 +664,7 @@ def test_append_panel_event_round_trips(tmp_path) -> None:
|
||||
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||
event = await events.append(
|
||||
instance.id, child.id, "completed", "panel", _now_stamp(), False,
|
||||
actor_child_id=child.id,
|
||||
actor_user_id=None,
|
||||
)
|
||||
assert event.actor_source == "panel"
|
||||
@@ -718,17 +719,31 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None:
|
||||
with pytest.raises(ValueError, match="must not carry"):
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||
actor_child_id=child.id,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
with pytest.raises(ValueError, match="requires actor_child_id"):
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel",
|
||||
_now_stamp(), True,
|
||||
)
|
||||
with pytest.raises(ValueError, match="must not carry actor_child_id"):
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "user", _now_stamp(), True,
|
||||
actor_user_id="user-1",
|
||||
actor_child_id=child.id,
|
||||
)
|
||||
with pytest.raises(ValueError, match="requires was_on_time"):
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", _now_stamp(), None
|
||||
instance.id, child.id, "completed", "panel", _now_stamp(), None,
|
||||
actor_child_id=child.id
|
||||
)
|
||||
# was_on_time must be a real bool: int-convertible junk that
|
||||
# would silently coerce (0.5 -> False) is rejected outright.
|
||||
with pytest.raises(ValueError, match="must be True, False or None"):
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", _now_stamp(), 0.5
|
||||
instance.id, child.id, "completed", "panel",
|
||||
_now_stamp(), 0.5, actor_child_id=child.id,
|
||||
)
|
||||
with pytest.raises(ValueError, match="must be True, False or None"):
|
||||
await events.append(
|
||||
@@ -758,7 +773,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None:
|
||||
with pytest.raises(ValueError, match="UTC"):
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", bad,
|
||||
True,
|
||||
True, actor_child_id=child.id,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -815,14 +830,16 @@ def test_completed_uncompleted_recompleted_three_ordered_events(
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", _now_stamp(), True
|
||||
instance.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||
actor_child_id=child.id
|
||||
)
|
||||
await events.append(
|
||||
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", _now_stamp(), False
|
||||
instance.id, child.id, "completed", "panel", _now_stamp(), False,
|
||||
actor_child_id=child.id
|
||||
)
|
||||
history = await events.list_by_instance(instance.id)
|
||||
assert [e.event_type for e in history] == [
|
||||
@@ -855,7 +872,8 @@ def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None:
|
||||
instance = await instances.upsert(
|
||||
definition.id, child.id, D1, _now_stamp(), window="morning")
|
||||
await events.append(
|
||||
instance.id, child.id, "completed", "panel", _now_stamp(), True
|
||||
instance.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||
actor_child_id=child.id
|
||||
)
|
||||
await events.append(
|
||||
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
||||
@@ -878,17 +896,20 @@ def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None:
|
||||
late = await instances.upsert(
|
||||
definition.id, child.id, D20, _now_stamp(), window="morning")
|
||||
await events.append(
|
||||
early.id, child.id, "completed", "panel", _now_stamp(), True
|
||||
early.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||
actor_child_id=child.id
|
||||
)
|
||||
await events.append(
|
||||
inside.id, child.id, "completed", "panel", _now_stamp(), False
|
||||
inside.id, child.id, "completed", "panel", _now_stamp(), False,
|
||||
actor_child_id=child.id
|
||||
)
|
||||
await events.append(
|
||||
inside.id, child.id, "uncompleted", "user", _now_stamp(), True,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
await events.append(
|
||||
late.id, child.id, "completed", "panel", _now_stamp(), True
|
||||
late.id, child.id, "completed", "panel", _now_stamp(), True,
|
||||
actor_child_id=child.id
|
||||
)
|
||||
found = await events.list_by_child_and_date_range(
|
||||
child.id, D2, D2
|
||||
@@ -922,7 +943,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None:
|
||||
# queried on today's date finds it too.
|
||||
event = await events.append(
|
||||
late_due.id, child.id, "completed", "panel",
|
||||
_now_stamp(), False,
|
||||
_now_stamp(), False, actor_child_id=child.id,
|
||||
)
|
||||
by_due = await events.list_by_child_and_date_range(
|
||||
child.id, D20, D20
|
||||
@@ -1010,7 +1031,12 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None:
|
||||
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
|
||||
insert_exempt = {
|
||||
"custom_components/nestquest/dao_instances.py",
|
||||
"custom_components/nestquest/migrations.py", # the rebuild
|
||||
# migrations re-create the table via INSERT..SELECT (append-
|
||||
# order preserved); UPDATE/DELETE stays forbidden everywhere
|
||||
"tests/test_schema.py",
|
||||
"tests/test_migrations.py", # seeds frozen legacy shapes to
|
||||
# exercise the rebuild migrations
|
||||
"tests/test_dao_instances.py", # this guard file; scanned by
|
||||
# its own guard's regex spans below instead
|
||||
}
|
||||
|
||||
@@ -372,14 +372,16 @@ def test_matrix_completion_events_append_read_and_no_mutation(
|
||||
async def _body(w: World):
|
||||
# insert: append a completion then a reversal, then re-complete
|
||||
await w.events.append(
|
||||
w.instance.id, w.child.id, "completed", "panel", _stamp(), True
|
||||
w.instance.id, w.child.id, "completed", "panel", _stamp(), True,
|
||||
actor_child_id=w.child.id,
|
||||
)
|
||||
await w.events.append(
|
||||
w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None,
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
await w.events.append(
|
||||
w.instance.id, w.child.id, "completed", "panel", _stamp(), False
|
||||
w.instance.id, w.child.id, "completed", "panel", _stamp(), False,
|
||||
actor_child_id=w.child.id,
|
||||
)
|
||||
# read: full ordered history + latest
|
||||
history = await w.events.list_by_instance(w.instance.id)
|
||||
|
||||
@@ -149,16 +149,17 @@ def test_migration_list_shape() -> None:
|
||||
(D-007) to create the quest_* tables directly; migration 2 exists
|
||||
for dev databases stamped version 1 by the pre-rename runner,
|
||||
migration 3 brings pre-D-008 definitions to the multi-assignee
|
||||
model, migration 4 adds the windows table, and migration 5 rebuilds
|
||||
model, migration 4 adds the windows table, migration 5 rebuilds
|
||||
quest_instances onto the widened (definition, child, date, window)
|
||||
key.
|
||||
key, and migration 6 adds actor_child_id to completion_events.
|
||||
"""
|
||||
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
|
||||
assert callable(MIGRATIONS[1])
|
||||
assert callable(MIGRATIONS[2])
|
||||
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
|
||||
assert callable(MIGRATIONS[4])
|
||||
assert len(MIGRATIONS) == 5
|
||||
assert callable(MIGRATIONS[5])
|
||||
assert len(MIGRATIONS) == 6
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -442,6 +443,21 @@ _V2_INSTANCES_DDL = [
|
||||
""",
|
||||
]
|
||||
|
||||
_V2_EVENTS_DDL = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS completion_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
event_type TEXT NOT NULL,
|
||||
actor_source TEXT NOT NULL,
|
||||
actor_user_id TEXT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
was_on_time INTEGER
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
def _seed_v2_multi_assignee_upgrade(database) -> None:
|
||||
"""Create a database shaped like a post-rename, pre-D-008 dev file.
|
||||
@@ -458,12 +474,13 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
|
||||
"quest_definition_assignees ",
|
||||
"quest_definition_windows ",
|
||||
"quest_instances ",
|
||||
"completion_events ",
|
||||
)
|
||||
for sql in SCHEMA_V1_STATEMENTS:
|
||||
if any(name in sql for name in _v2_replaced):
|
||||
continue
|
||||
_run(database.execute(sql))
|
||||
for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL:
|
||||
for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL + _V2_EVENTS_DDL:
|
||||
_run(database.execute(sql))
|
||||
_run(database.execute(VERSION_TABLE_DDL))
|
||||
_run(
|
||||
@@ -503,6 +520,26 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
|
||||
"VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')"
|
||||
)
|
||||
)
|
||||
# One legacy panel event and one legacy admin event: the panel one
|
||||
# must backfill actor_child_id to its own child; the admin one must
|
||||
# stay NULL.
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO completion_events (instance_id, child_id, "
|
||||
"event_type, actor_source, occurred_at, was_on_time) "
|
||||
"VALUES (1, 2, 'completed', 'panel', "
|
||||
"'2026-09-15T10:00:00+00:00', 1)"
|
||||
)
|
||||
)
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO completion_events (instance_id, child_id, "
|
||||
"event_type, actor_source, actor_user_id, occurred_at, "
|
||||
"was_on_time) "
|
||||
"VALUES (1, 2, 'uncompleted', 'user', 'parent-1', "
|
||||
"'2026-09-15T11:00:00+00:00', 1)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
||||
@@ -559,6 +596,23 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
||||
assert [
|
||||
"definition_id", "child_id", "due_date", "window"
|
||||
] in unique_columns
|
||||
# The completion events survived the actor_child_id rebuild:
|
||||
# the panel row backfilled to its own child, the admin row
|
||||
# stayed NULL, and ids are preserved.
|
||||
events = _run(
|
||||
database.fetch_all(
|
||||
"SELECT id, event_type, actor_source, actor_child_id "
|
||||
"FROM completion_events ORDER BY id"
|
||||
)
|
||||
)
|
||||
assert events == [
|
||||
(1, "completed", "panel", 2),
|
||||
(2, "uncompleted", "user", None),
|
||||
]
|
||||
columns = _run(
|
||||
database.fetch_all("PRAGMA table_info(completion_events)")
|
||||
)
|
||||
assert "actor_child_id" in {c[1] for c in columns}
|
||||
# No dangling references anywhere.
|
||||
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
|
||||
# The rebuilt table is live for the new model: a definition
|
||||
|
||||
+60
-3
@@ -1851,6 +1851,7 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None:
|
||||
("event_type", "TEXT", 1, None, 0),
|
||||
("actor_source", "TEXT", 1, None, 0),
|
||||
("actor_user_id", "TEXT", 0, None, 0),
|
||||
("actor_child_id", "INTEGER", 0, None, 0),
|
||||
("occurred_at", "TEXT", 1, None, 0),
|
||||
("was_on_time", "INTEGER", 0, None, 0),
|
||||
]
|
||||
@@ -1881,6 +1882,7 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None:
|
||||
assert declared == {
|
||||
("quest_instances", "instance_id", "id"),
|
||||
("children", "child_id", "id"),
|
||||
("children", "actor_child_id", "id"),
|
||||
}
|
||||
finally:
|
||||
_run(database.close())
|
||||
@@ -2182,16 +2184,71 @@ def test_completion_events_valid_panel_completion_round_trips(tmp_path) -> None:
|
||||
instance_id=instance_id,
|
||||
actor_source="panel",
|
||||
actor_user_id=None,
|
||||
actor_child_id=1,
|
||||
was_on_time=0,
|
||||
)
|
||||
row = _run(
|
||||
database.fetch_one(
|
||||
"SELECT actor_source, actor_user_id, was_on_time "
|
||||
"FROM completion_events WHERE id = ?",
|
||||
"SELECT actor_source, actor_user_id, actor_child_id, "
|
||||
"was_on_time FROM completion_events WHERE id = ?",
|
||||
(event_id,),
|
||||
)
|
||||
)
|
||||
assert row == ("panel", None, 0)
|
||||
assert row == ("panel", None, 1, 0)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("actor_source", "actor_user_id", "actor_child_id"),
|
||||
[
|
||||
("user", "user-1", 1), # admin event must not carry a profile
|
||||
("panel", None, None), # panel event requires the profile
|
||||
("panel", "user-1", 1), # panel must not carry a user id either
|
||||
("panel", "user-1", None),
|
||||
("user", None, None),
|
||||
],
|
||||
)
|
||||
def test_completion_events_actor_child_coherence_enforced(
|
||||
tmp_path, actor_source, actor_user_id, actor_child_id
|
||||
) -> None:
|
||||
"""D-008 actor-pair shape: 'user' events carry actor_user_id and no
|
||||
actor_child_id; 'panel' events carry actor_child_id and no
|
||||
actor_user_id. The CHECKs make both halves non-negotiable."""
|
||||
database = _open_db(tmp_path / "completion-events-coherence.db")
|
||||
try:
|
||||
_apply(database)
|
||||
_setup_definition(database)
|
||||
instance_id = _insert_instance(database, definition_id=1)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
|
||||
_insert_event(
|
||||
database,
|
||||
instance_id=instance_id,
|
||||
event_type="completed",
|
||||
actor_source=actor_source,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_child_id=actor_child_id,
|
||||
was_on_time=1,
|
||||
)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_completion_events_actor_child_unknown_child_fails(tmp_path) -> None:
|
||||
database = _open_db(tmp_path / "completion-events-bad-actor-child.db")
|
||||
try:
|
||||
_apply(database)
|
||||
_setup_definition(database)
|
||||
instance_id = _insert_instance(database, definition_id=1)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
|
||||
_insert_event(
|
||||
database,
|
||||
instance_id=instance_id,
|
||||
actor_source="panel",
|
||||
actor_user_id=None,
|
||||
actor_child_id=999,
|
||||
was_on_time=1,
|
||||
)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user