Core, API and Admin PWA: multiple presence patterns per child (schema 9) (#231)

This commit was merged in pull request #231.
This commit is contained in:
2026-09-24 20:09:56 -04:00
parent 5650a1f5a2
commit 88b4733a2d
36 changed files with 4230 additions and 1388 deletions
+123 -74
View File
@@ -1,4 +1,4 @@
"""Typed DAO layer for the presence_schedules and presence_overrides tables.
"""Typed DAO layer for the presence_patterns and presence_overrides tables.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
@@ -8,11 +8,12 @@ executor and the event loop never blocks.
Semantics per the done-condition and Feature 05:
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting
by child updates the existing row rather than raising.
- A child with NO schedule row is present every day — the absence of a
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child`
returning None, never by an empty pattern row.
- A child has ZERO OR MORE presence patterns (schema 9 — no UNIQUE on
``child_id``); each is created, updated and deleted by its own id.
- A child with NO pattern row is present every day — the absence of a
row is meaningful state, expressed by
:meth:`PresencePatternsDao.list_by_child` returning an empty list,
never by an empty pattern row.
- Overrides beat the pattern for their date range (that resolution is
the presence engine's job, Feature 05); the DAO only stores and
lists them. A single-day override stores the same date as both
@@ -32,11 +33,13 @@ from .db import NestQuestDatabase
@dataclass(frozen=True)
class PresenceScheduleRecord:
"""One row of ``presence_schedules``."""
class PresencePatternRecord:
"""One row of ``presence_patterns``."""
id: int
child_id: int
name: str
kind: str
cycle_length_weeks: int
anchor_date: str
pattern: str
@@ -54,21 +57,23 @@ class PresenceOverrideRecord:
note: str | None
_SCHEDULE_COLUMNS = (
"id, child_id, cycle_length_weeks, anchor_date, pattern"
_PATTERN_COLUMNS = (
"id, child_id, name, kind, cycle_length_weeks, anchor_date, pattern"
)
_OVERRIDE_COLUMNS = (
"id, child_id, start_date, end_date, is_present, note"
)
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord:
return PresenceScheduleRecord(
def _pattern_from_row(row: tuple) -> PresencePatternRecord:
return PresencePatternRecord(
id=row[0],
child_id=row[1],
cycle_length_weeks=row[2],
anchor_date=row[3],
pattern=row[4],
name=row[2],
kind=row[3],
cycle_length_weeks=row[4],
anchor_date=row[5],
pattern=row[6],
)
@@ -83,29 +88,27 @@ def _override_from_row(row: tuple) -> PresenceOverrideRecord:
)
class PresenceSchedulesDao:
"""Typed async access to the ``presence_schedules`` table."""
class PresencePatternsDao:
"""Typed async access to the ``presence_patterns`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert_by_child(
async def create(
self,
child_id: int,
name: str,
kind: str,
cycle_length_weeks: int,
anchor_date: str,
pattern: str,
) -> PresenceScheduleRecord:
"""Set the child's presence schedule, replacing any existing one.
) -> PresencePatternRecord:
"""Insert one pattern for the child and return it as stored.
Upsert semantics are the done-condition: a second schedule for
the same child updates the existing row rather than raising.
Runs under the connection lock so a concurrent upsert/delete
for the same child serializes instead of colliding on the
schema's UNIQUE(child_id). Pattern validity (segment shape,
segment count = cycle_length_weeks) is enforced by the schema
CHECKs; the child must exist (FK) — validated atomically here
so a deleted child cannot pass a stale check.
The child must exist (FK) — validated atomically under the
connection lock so a deleted child cannot pass a stale check.
``kind``, the cycle length and the pattern shape (segment count
= ``cycle_length_weeks``) are enforced by the schema CHECKs.
"""
_validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database):
@@ -115,53 +118,98 @@ class PresenceSchedulesDao:
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
await self._database.execute(
"INSERT INTO presence_schedules (child_id, "
result = await self._database.execute(
"INSERT INTO presence_patterns (child_id, name, kind, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT (child_id) DO UPDATE SET "
"cycle_length_weeks = excluded.cycle_length_weeks, "
"anchor_date = excluded.anchor_date, "
"pattern = excluded.pattern",
(child_id, cycle_length_weeks, anchor_date, pattern),
"VALUES (?, ?, ?, ?, ?, ?)",
(
child_id,
name,
kind,
cycle_length_weeks,
anchor_date,
pattern,
),
)
schedule = await self.get_by_child(child_id)
assert schedule is not None
return schedule
record = await self.get(result.lastrowid)
assert record is not None
return record
async def get_by_child(
self, child_id: int
) -> PresenceScheduleRecord | None:
"""Return the child's schedule, or None when the child has none.
async def list_by_child(self, child_id: int) -> list[PresencePatternRecord]:
"""Return the child's patterns ordered by id; empty when none.
None is meaningful and UNAMBIGUOUS here: the child exists and
has no schedule row, i.e. the child is present every day
(Feature 05 guardrail). An unknown child id raises ValueError
instead of returning None, because "nonexistent child" must
never be silently read as "present every day".
An empty list is meaningful and UNAMBIGUOUS here: the child
exists and has no pattern row, i.e. the child is present every
day (Feature 05 guardrail). An unknown child id raises
ValueError instead of returning an empty list, because
"nonexistent child" must never be silently read as "present
every day".
"""
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
row = await self._database.fetch_one(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
"WHERE child_id = ?",
rows = await self._database.fetch_all(
f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns "
"WHERE child_id = ? ORDER BY id",
(child_id,),
)
return _schedule_from_row(row) if row is not None else None
return [_pattern_from_row(row) for row in rows]
async def delete(self, child_id: int) -> bool:
"""Remove the child's schedule; True if a row was removed.
async def get(self, pattern_id: int) -> PresencePatternRecord | None:
"""Return one pattern by id, or None when it does not exist."""
row = await self._database.fetch_one(
f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns WHERE id = ?",
(pattern_id,),
)
return _pattern_from_row(row) if row is not None else None
After deletion the child is present every day (no row means
no restriction). Deleting a schedule the child never had
returns False.
async def update(
self,
pattern_id: int,
name: str,
kind: str,
cycle_length_weeks: int,
anchor_date: str,
pattern: str,
) -> PresencePatternRecord | None:
"""Replace one pattern's fields; None when the id does not exist.
Every column but ``id`` and ``child_id`` is rewritten — a
pattern never moves to another child. The schema CHECKs apply
exactly as on :meth:`create`.
"""
_validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database):
async with self._database.transaction():
result = await self._database.execute(
"UPDATE presence_patterns SET name = ?, kind = ?, "
"cycle_length_weeks = ?, anchor_date = ?, pattern = ? "
"WHERE id = ?",
(
name,
kind,
cycle_length_weeks,
anchor_date,
pattern,
pattern_id,
),
)
if result.rowcount == 0:
return None
record = await self.get(pattern_id)
return record
async def delete(self, pattern_id: int) -> bool:
"""Remove one pattern by id; True if a row was removed.
Deleting a child's last pattern leaves it present every day (no
row means no restriction).
"""
result = await self._database.execute(
"DELETE FROM presence_schedules WHERE child_id = ?",
(child_id,),
"DELETE FROM presence_patterns WHERE id = ?",
(pattern_id,),
)
return result.rowcount > 0
@@ -345,22 +393,23 @@ async def read_snapshot_unlocked(
range_start: str,
range_end: str,
) -> tuple[
dict[int, PresenceScheduleRecord],
dict[int, list[PresencePatternRecord]],
dict[int, list[PresenceOverrideRecord]],
]:
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
"""Read patterns and overrides for ``child_ids`` WITHOUT locking.
MUST be called inside the connection lock and an open transaction, so
the schedules and overrides returned here stay coherent with every
the patterns and overrides returned here stay coherent with every
other table the caller reads inside that same transaction (the
materialization walk's single input snapshot does exactly that). The
date range comes already validated by the caller.
Returns ``(schedules, overrides)``:
Returns ``(patterns, overrides)``:
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
for every child that HAS a schedule row. A child absent from the
mapping has no schedule, i.e. is present every day (Feature 05).
- ``patterns`` maps ``child_id`` to its :class:`PresencePatternRecord`
list (ordered by id) for every child that HAS a pattern row. A
child absent from the mapping has no pattern, i.e. is present
every day (Feature 05).
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
``[range_start, range_end]`` (inclusive boundaries), ordered by start
date; a child with no matching override is absent from the mapping.
@@ -372,9 +421,9 @@ async def read_snapshot_unlocked(
if not children:
return {}, {}
placeholders = ",".join("?" for _ in children)
schedule_rows = await database.fetch_all(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
f"WHERE child_id IN ({placeholders})",
pattern_rows = await database.fetch_all(
f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns "
f"WHERE child_id IN ({placeholders}) ORDER BY id",
tuple(children),
)
override_rows = await database.fetch_all(
@@ -383,12 +432,12 @@ async def read_snapshot_unlocked(
"AND start_date <= ? ORDER BY start_date",
tuple(children) + (range_start, range_end),
)
schedules = {
record.child_id: record
for record in map(_schedule_from_row, schedule_rows)
}
patterns: dict[int, list[PresencePatternRecord]] = {}
for row in pattern_rows:
record = _pattern_from_row(row)
patterns.setdefault(record.child_id, []).append(record)
overrides: dict[int, list[PresenceOverrideRecord]] = {}
for row in override_rows:
record = _override_from_row(row)
overrides.setdefault(record.child_id, []).append(record)
return schedules, overrides
return patterns, overrides
@@ -1319,16 +1319,16 @@ async def load_materialization_input(
):
"""Read every materialization input in ONE locked transaction.
Returns ``(snapshots, schedules, overrides)``:
Returns ``(snapshots, patterns, overrides)``:
- ``snapshots``: every active definition with its rule, assignees and
windows (the :class:`QuestDefinitionSnapshot` shape).
- ``schedules`` / ``overrides``: the presence schedules and
- ``patterns`` / ``overrides``: the presence patterns and
range-scoped overrides of every assignee child, in
:mod:`.dao_presence`'s record shapes.
The whole read — definitions, rules, assignees, windows, and the
assignees' presence schedules and overrides — runs inside a single
assignees' presence patterns and overrides — runs inside a single
BEGIN..COMMIT span under the connection lock, so a concurrent edit
(definition, assignment, window, or presence) cannot interleave
between the parts and the walk never sees a mixed-time view. Each
@@ -1349,7 +1349,7 @@ async def load_materialization_input(
)
from . import dao_presence # lazy: dao_presence imports us
schedules, overrides = await dao_presence.read_snapshot_unlocked(
patterns, overrides = await dao_presence.read_snapshot_unlocked(
database, child_ids, range_start, range_end
)
return snapshots, schedules, overrides
return snapshots, patterns, overrides
+33 -28
View File
@@ -9,7 +9,7 @@ task and lives elsewhere.
The walk is deterministic and idempotent:
- All inputs — the active definitions with their rules, assignees and
windows, AND the assignee children's presence schedules and scoped
windows, AND the assignee children's presence patterns and scoped
overrides — are read inside ONE locked transaction (see
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
single coherent snapshot rather than a definition state from one
@@ -51,12 +51,12 @@ never the past — and re-run the walk over the rolling horizon
HOOK (Feature 09 presence services): the presence business layer that
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
presence-state write so a child's future instances track its presence:
``set_presence_pattern`` (including clearing/setting an empty pattern),
deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
creating, updating and deleting a presence pattern
(:mod:`.presence_management`'s ``create_presence_pattern`` /
``update_presence_pattern`` / ``delete_presence_pattern``),
``create_presence_override``, and ``delete_presence_override``. A
schedule delete changes presence state too (the child becomes
always-present), so it must regenerate just like the others. This hook
pattern delete changes presence state too, so it must regenerate just
like the others. This hook
is documented, not yet wired: no presence business layer exists yet, and
the presence DAO (``dao_presence.py``) must stay free of business rules
— never call :func:`regenerate_for_child` from inside the DAO layer.
@@ -79,7 +79,7 @@ from .dao_rules import (
schedule_rule_storage_from_record,
)
from .db import NestQuestDatabase
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
from .presence import PresenceEngine, PresenceOverride, PresencePattern
from .recurrence import ScheduleRule, occurs_on
@@ -129,7 +129,7 @@ def _decode_rule(snapshot) -> ScheduleRule:
def _build_engine(
schedules_records,
patterns_records,
overrides_records,
*,
extra_overrides: tuple[PresenceOverride, ...] = (),
@@ -140,11 +140,18 @@ def _build_engine(
overrides on top of the stored ones — the consequence preview's
proposed override (:func:`count_removed_by_override`).
"""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
child_id, record.anchor_date, record.pattern
)
patterns: dict[int, list[PresencePattern]] = {}
for child_id, records in patterns_records.items():
patterns[child_id] = [
PresencePattern.decode(
record.child_id,
record.name,
record.kind,
record.anchor_date,
record.pattern,
)
for record in records
]
overrides: dict[int, list[PresenceOverride]] = {}
for child_id, records in overrides_records.items():
overrides[child_id] = [
@@ -159,7 +166,7 @@ def _build_engine(
]
for override in extra_overrides:
overrides.setdefault(override.child_id, []).append(override)
return PresenceEngine(schedules, overrides)
return PresenceEngine(patterns, overrides)
class GeneratedTuple(NamedTuple):
@@ -279,10 +286,10 @@ async def materialize(
f"{end_date!r} < {start_date!r}"
)
snapshots, schedules_records, overrides_records = await (
snapshots, patterns_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
engine = _build_engine(schedules_records, overrides_records)
engine = _build_engine(patterns_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids)
generated_at = _now_stamp()
@@ -366,12 +373,11 @@ async def regenerate_for_definition(
# child-presence counterpart to ``regenerate_for_definition``. The
# presence business layer that lands in Feature 09 MUST call
# ``regenerate_for_child`` after EVERY presence-state write, so future
# instances track a child's changed presence: set_presence_pattern
# (including clearing/setting an empty pattern), deleting/clearing the
# presence schedule (PresenceSchedulesDao.delete),
# create_presence_override, and delete_presence_override. A schedule
# delete changes presence state too (the child becomes always-present),
# so it must regenerate just like the others. It is deliberately NOT
# instances track a child's changed presence: create_presence_pattern,
# update_presence_pattern, delete_presence_pattern,
# create_presence_override, and delete_presence_override. A pattern
# delete changes presence state too, so it must regenerate just like
# the others. It is deliberately NOT
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
# business rules), and no presence business layer exists yet to host the
# call — see the module docstring note above.
@@ -385,9 +391,8 @@ async def regenerate_for_child(
"""Regenerate a child's future instances after a presence change.
The Feature 09 presence services call this after every
presence-state write — set_presence_pattern (including clearing or
setting an empty pattern), deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
presence-state write — create_presence_pattern,
update_presence_pattern, delete_presence_pattern,
create_presence_override, and delete_presence_override — so a
child's future instances track its new presence: the child's open
instances at or after today are deleted across ALL its definitions
@@ -481,12 +486,12 @@ async def count_removed_by_override(
start_date, end_date = start.isoformat(), end.isoformat()
child_id = override.child_id
snapshots, schedules_records, overrides_records = await (
snapshots, patterns_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
current = _generation_preview(
snapshots,
_build_engine(schedules_records, overrides_records),
_build_engine(patterns_records, overrides_records),
start,
end,
child_filter={child_id},
@@ -495,7 +500,7 @@ async def count_removed_by_override(
_generation_preview(
snapshots,
_build_engine(
schedules_records,
patterns_records,
overrides_records,
extra_overrides=(override,),
),
+78 -3
View File
@@ -31,7 +31,11 @@ widened (definition_id, child_id, due_date, window) key, and migration
column. Migration 7 adds the generic ``nestquest_meta_state``
key/value table for integration-owned bookkeeping (Feature 11's
missed-sweep watermark). Migration 8 adds the ``skip_on_away``
column to ``quest_definitions`` (Feature 22), defaulting to 1. Each
column to ``quest_definitions`` (Feature 22), defaulting to 1.
Migration 9 replaces the one-per-child ``presence_schedules`` table
with ``presence_patterns`` (zero or more patterns per child), copying
every schedule across as a ``home`` pattern and dropping the old table
— see :data:`MIGRATION_9_PRESENCE_PATTERNS` for the rollback note. 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.
@@ -85,6 +89,7 @@ from .schema import (
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
SCHEMA_V7_META_STATE_DDL,
SCHEMA_V9_PRESENCE_PATTERNS_DDL,
)
LOGGER = logging.getLogger(__name__)
@@ -116,9 +121,76 @@ VERSION_TABLE_DDL = f"""
)
"""
#: The retired ``presence_schedules`` DDL (one schedule per child),
#: frozen here verbatim: migration 1 shipped creating this table and
#: applied migrations are never edited, so a fresh file still walks
#: through it and migration 9 retires it. :mod:`.schema` no longer
#: declares it.
_MIGRATION_1_PRESENCE_SCHEDULES_DDL = """
CREATE TABLE IF NOT EXISTS presence_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL CHECK (
NOT pattern GLOB '*[^0-6,|]*'
AND NOT pattern GLOB '*[0-6][0-6]*'
AND NOT pattern GLOB '*,,*'
AND NOT pattern GLOB '*,|*'
AND NOT pattern GLOB '*|,*'
AND NOT pattern GLOB ',*'
AND NOT pattern GLOB '*,'
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
)
)
"""
#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades
#: version ``n-1`` to version ``n``.
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
#: version ``n-1`` to version ``n``. Migration 1 is the v1 DDL as it
#: shipped: the current tables, except that it still creates the
#: retired ``presence_schedules`` table in place of
#: ``presence_patterns`` (which migration 9 creates).
MIGRATION_1_V1_DDL: list[str] = [
_MIGRATION_1_PRESENCE_SCHEDULES_DDL
if sql in SCHEMA_V9_PRESENCE_PATTERNS_DDL
else sql
for sql in SCHEMA_V1_STATEMENTS
]
#: The label every migrated ``presence_schedules`` row carries as a
#: ``presence_patterns`` row.
MIGRATED_PRESENCE_PATTERN_NAME = "Home schedule"
#: Migration 9 (schema 9): multiple presence patterns per child.
#: Creates ``presence_patterns``, copies EVERY ``presence_schedules``
#: row into it as a ``home`` pattern named
#: :data:`MIGRATED_PRESENCE_PATTERN_NAME` — ``cycle_length_weeks``,
#: ``anchor_date`` and ``pattern`` verbatim, the row id kept — and
#: drops ``presence_schedules``. A ``home`` pattern with no other
#: pattern beside it answers exactly as the old schedule did (the
#: engine treats a date no home pattern covers as a day away), so every
#: child's presence is unchanged. A plain statement list: the runner
#: applies it and the version stamp in ONE transaction, so a failure
#: rolls the copy and the drop back together. Dropping a table nothing
#: references needs no ``foreign_keys = OFF`` bracket.
#:
#: Rollback: the drop is one-way. A 0.7.1 build refuses a version-9
#: file ("newer than this integration understands") and could not read
#: ``presence_patterns`` anyway, so take a copy of the SQLite file (or
#: note the Litestream generation) BEFORE the first start of the release
#: carrying migration 9; rolling back to 0.7.1 means restoring that
#: pre-migration copy, which still holds the single-schedule rows.
#: Pattern edits made after the upgrade are not in that copy.
MIGRATION_9_PRESENCE_PATTERNS: list[str] = [
*SCHEMA_V9_PRESENCE_PATTERNS_DDL,
"INSERT INTO presence_patterns (id, child_id, name, kind, "
"cycle_length_weeks, anchor_date, pattern) "
f"SELECT id, child_id, '{MIGRATED_PRESENCE_PATTERN_NAME}', 'home', "
"cycle_length_weeks, anchor_date, pattern "
"FROM presence_schedules ORDER BY id",
"DROP TABLE presence_schedules",
]
#: The assignees table DDL, shared with migration 1 via the schema
#: module so a rebuilt and a freshly created table cannot drift.
@@ -450,6 +522,9 @@ MIGRATIONS: Sequence[MigrationStep] = [
# quest_definitions — an additive column defaulting to 1, so every
# pre-existing definition keeps today's skip-when-absent behaviour.
_add_skip_on_away,
# Migration 9 (schema 9): presence_schedules -> presence_patterns,
# each schedule copied across as a 'home' pattern.
MIGRATION_9_PRESENCE_PATTERNS,
]
+139 -70
View File
@@ -5,21 +5,24 @@ timezone logic — it operates on plain calendar dates only (feature
guardrail). The later engine (cycle position, ``is_present``,
overrides, previews) builds directly on this model.
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
week is this child at this house?" for an N-week repeating custody
pattern. Two of the household's three children follow a two-week
rotation; a child with NO schedule row is present every day (that
default lives in the engine, not here).
A :class:`PresencePattern` answers "on which weekdays of which cycle
week does this rule apply to this child?" for an N-week repeating
custody rule, and its ``kind`` says what the rule claims on those
days: ``home`` (the child is at this house) or ``away``. A child
carries ZERO OR MORE patterns (schema 9), e.g. "away every Thursday
and Friday" plus "away every other weekend"; a child with NO pattern
is present every day (that default, and how patterns combine, lives in
the engine, not here).
Pattern encoding: the model's canonical serialization is the SAME
pipe-separated CSV the database stores (see schema.py's
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
empty segment means absent every day of that week. Encoding sorts
each segment's weekdays and uses the exact segment order, so
``decode(encode(schedule))`` round-trips losslessly and two schedules
with equal patterns produce equal strings.
``presence_patterns.pattern``): segment ``i`` (week index ``i``) is a
comma-separated list of the weekdays the pattern covers, 0 = Monday ..
6 = Sunday, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
week 1. An empty segment means the pattern covers no day of that
week. Encoding sorts each segment's weekdays and uses the exact
segment order, so ``decode(encode(pattern))`` round-trips losslessly
and two patterns with equal weekday sets produce equal strings.
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
computed from anchor-date arithmetic ONLY — never ISO week numbers or
@@ -28,7 +31,9 @@ week parity (D-004): 53-week years silently invert parity on January
that the anchor is a strict calendar date.
Validation policy: construction is total — every reject happens in
``__init__`` so no invalid schedule can ever exist:
``__init__`` so no invalid pattern can ever exist:
- ``name`` must be a string that is not blank; it is stored trimmed.
- ``kind`` must be ``'home'`` or ``'away'``.
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
the database CHECK (schema.py) and keeps anchor arithmetic and the
encoded pattern human-readable; revisit both together if a longer
@@ -49,6 +54,10 @@ from types import MappingProxyType
#: the pattern human-readable and the anchor arithmetic small).
MAX_CYCLE_LENGTH_WEEKS = 4
#: The pattern kinds the schema's CHECK allows: what a covering
#: pattern claims about the child on that date.
PATTERN_KINDS = ("home", "away")
_DATE_FORMAT = "%Y-%m-%d"
@@ -126,6 +135,25 @@ def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
return frozenset(members)
def _validate_name(value: object) -> str:
"""Reject non-string and blank pattern names; return it trimmed."""
if not isinstance(value, str):
raise ValueError(f"name must be a string, got {value!r}")
trimmed = value.strip()
if not trimmed:
raise ValueError("name must not be empty")
return trimmed
def _validate_kind(value: object) -> str:
"""Reject any kind other than the schema's ``home`` / ``away``."""
if not isinstance(value, str) or value not in PATTERN_KINDS:
raise ValueError(
f"kind must be one of {', '.join(PATTERN_KINDS)}, got {value!r}"
)
return value
def _validate_cycle_length(value: object) -> int:
"""Reject non-int and out-of-range cycle lengths (bools included)."""
if isinstance(value, bool) or not isinstance(value, int):
@@ -148,17 +176,19 @@ def _validate_child_id(value: object) -> int:
@dataclass(frozen=True, init=False)
class PresenceSchedule:
"""One child's N-week repeating presence pattern.
class PresencePattern:
"""One of a child's N-week repeating presence rules.
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
to the set of present weekdays (ints, Monday=0). A week with an
empty set means the child is absent every day of that week — which
is distinct from having no schedule at all (the engine treats a
missing schedule as present every day).
to the set of weekdays the rule covers (ints, Monday=0); ``kind``
is what the rule claims on a covered date (``home`` or ``away``).
A week with an empty set covers no day of that week. How several
patterns combine is the engine's job (:class:`PresenceEngine`).
"""
child_id: int
name: str
kind: str
cycle_length_weeks: int
anchor_date: datetime.date
pattern: MappingProxyType # week index -> frozenset of weekdays
@@ -166,11 +196,15 @@ class PresenceSchedule:
def __init__(
self,
child_id: int,
name: str,
kind: str,
cycle_length_weeks: int,
anchor_date: object,
pattern: dict[int, object],
) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id))
object.__setattr__(self, "name", _validate_name(name))
object.__setattr__(self, "kind", _validate_kind(kind))
cycle = _validate_cycle_length(cycle_length_weeks)
object.__setattr__(self, "cycle_length_weeks", cycle)
object.__setattr__(
@@ -178,7 +212,7 @@ class PresenceSchedule:
)
if not isinstance(pattern, dict):
raise ValueError(
"pattern must map week index to a set of present "
"pattern must map week index to a set of covered "
f"weekdays, got {pattern!r}"
)
normalised: dict[int, frozenset[int]] = {}
@@ -204,9 +238,9 @@ class PresenceSchedule:
"""Serialize to the database's pipe-separated CSV shape.
Segments appear in week order; each segment is the week's
present weekdays as a sorted comma-separated list (an empty set
encodes as an empty segment). Two schedules with equal
patterns encode identically.
covered weekdays as a sorted comma-separated list (an empty set
encodes as an empty segment). Two patterns with equal weekday
sets encode identically.
"""
segments = [
",".join(str(day) for day in sorted(self.pattern[week]))
@@ -218,10 +252,12 @@ class PresenceSchedule:
def decode(
cls,
child_id: int,
name: str,
kind: str,
anchor_date: object,
encoded: str,
) -> "PresenceSchedule":
"""Build a schedule from its encoded pattern.
) -> "PresencePattern":
"""Build a pattern from its encoded weekday sets.
The cycle length is derived from the segment count, mirroring
the database's segment-count CHECK. Decoding is strict: wrong
@@ -248,7 +284,15 @@ class PresenceSchedule:
)
weekdays.add(int(part))
pattern[week] = weekdays
return cls(child_id, len(segments), anchor_date, pattern)
return cls(child_id, name, kind, len(segments), anchor_date, pattern)
def covers(self, date: datetime.date) -> bool:
"""Return True when ``date``'s weekday is in its cycle week's set.
The cycle week comes from :func:`cycle_week_index` — anchor-date
arithmetic only, never ISO week numbers or parity (D-004).
"""
return date.weekday() in self.pattern[cycle_week_index(self, date)]
@dataclass(frozen=True, init=False)
@@ -301,7 +345,7 @@ class PresenceOverride:
def cycle_week_index(
schedule: PresenceSchedule, target_date: object
pattern: PresencePattern, target_date: object
) -> int:
"""Return the cycle week index ``target_date`` falls in.
@@ -315,14 +359,14 @@ def cycle_week_index(
valid week index.
"""
target = _parse_date(target_date, "target_date")
days = (target - schedule.anchor_date).days
return (days // 7) % schedule.cycle_length_weeks
days = (target - pattern.anchor_date).days
return (days // 7) % pattern.cycle_length_weeks
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
#: years of daily checks. A child absent every day (an all-absent
#: pattern with a blocking override) would otherwise loop forever; the
#: cap bounds the scan and surfaces the situation as an error instead.
#: years of daily checks. A child absent every day (an away pattern
#: covering every weekday) would otherwise loop forever; the cap bounds
#: the scan and surfaces the situation as an error instead.
MAX_PREVIEW_SCAN_DAYS = 366 * 2
@@ -330,40 +374,45 @@ class PresenceEngine:
"""Answers "is this child at this house on this date?".
Pure and side-effect-free: built from an immutable snapshot of the
children's schedules (``child_id -> PresenceSchedule``), it never
touches the database — callers build a snapshot from the DAO layer
and pass it in. A child with NO schedule entry is present every
day (the third household child's case); a child WITH a schedule is
present exactly when the date's weekday is in that schedule's
pattern for the date's cycle week index (anchor-date arithmetic —
:func:`cycle_week_index`, never ISO week parity).
children's patterns (``child_id -> list of PresencePattern``), it
never touches the database — callers build a snapshot from the DAO
layer and pass it in. A child with NO pattern is present every day
(the third household child's case). Evaluation order, first match
wins — see :meth:`is_present`. Pattern coverage is anchor-date
arithmetic (:func:`cycle_week_index`, never ISO week parity).
"""
def __init__(
self,
schedules: dict[int, PresenceSchedule],
patterns: dict[int, list[PresencePattern]],
overrides: dict[int, list[PresenceOverride]] | None = None,
) -> None:
if not isinstance(schedules, dict):
if not isinstance(patterns, dict):
raise ValueError(
f"schedules must map child_id to PresenceSchedule, got "
f"{schedules!r}"
"patterns must map child_id to a list of PresencePattern, "
f"got {patterns!r}"
)
for child_id, schedule in schedules.items():
for child_id, entries in patterns.items():
# Validate the KEY as a model child id too: True and 1.0
# compare equal to 1, so {True: schedule(1)} would be
# compare equal to 1, so {True: [pattern(1)]} would be
# accepted and then behave differently on lookup.
_validate_child_id(child_id)
if not isinstance(schedule, PresenceSchedule):
if not isinstance(entries, list):
raise ValueError(
f"schedules[{child_id!r}] must be a PresenceSchedule, "
f"got {schedule!r}"
)
if schedule.child_id != child_id:
raise ValueError(
f"schedules is keyed {child_id!r} but holds the "
f"schedule of child {schedule.child_id}"
f"patterns[{child_id!r}] must be a list of "
f"PresencePattern, got {entries!r}"
)
for entry in entries:
if not isinstance(entry, PresencePattern):
raise ValueError(
f"patterns[{child_id!r}] entries must be "
f"PresencePattern, got {entry!r}"
)
if entry.child_id != child_id:
raise ValueError(
f"patterns is keyed {child_id!r} but holds a "
f"pattern of child {entry.child_id}"
)
if overrides is None:
overrides = {}
if not isinstance(overrides, dict):
@@ -391,11 +440,17 @@ class PresenceEngine:
)
# Read-only snapshot: reassignment is refused and no mutator
# exists, so a built engine answers consistently for its
# lifetime. The overrides lists are copied to immutable
# tuples — a shallow mapping copy alone would leave caller-
# owned lists reachable through _overrides, and mutating one
# later would change is_present results.
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
# lifetime. The patterns and overrides lists are copied to
# immutable tuples — a shallow mapping copy alone would leave
# caller-owned lists reachable, and mutating one later would
# change is_present results.
object.__setattr__(
self,
"_patterns",
MappingProxyType(
{child_id: tuple(entries) for child_id, entries in patterns.items()}
),
)
object.__setattr__(
self,
"_overrides",
@@ -407,12 +462,22 @@ class PresenceEngine:
def is_present(self, child_id: object, date: object) -> bool:
"""Return whether ``child_id`` is present on ``date``.
Overrides take precedence over the repeating pattern: when an
override covers the date, its flag IS the answer — including
for a child with no schedule at all. The DAO layer guarantees
a child's overrides never overlap; if a snapshot still carries
two covering the same date, the one with the LATEST start date
wins (deterministic, and matches "the most specific swap wins").
Ordered, first match wins:
1. An override covering the date — its flag IS the answer,
including for a child with no pattern at all. The DAO layer
guarantees a child's overrides never overlap; if a snapshot
still carries two covering the same date, the one with the
LATEST start date wins (deterministic, and matches "the most
specific swap wins").
2. Any ``away`` pattern covering the date — absent.
3. Any ``home`` pattern covering the date — present.
4. Otherwise: absent when the child has at least one ``home``
pattern (a home pattern lists the days the child IS here, so
an uncovered date is a day away — the retired single-schedule
semantics, which migration 9 carries across as ``home``
patterns unchanged), else present (no pattern, or ``away``
patterns only: the household default).
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
@@ -424,12 +489,16 @@ class PresenceEngine:
if covering:
winner = max(covering, key=lambda entry: entry.start_date)
return winner.is_present
schedule = self._schedules.get(child_id)
if schedule is None:
# No schedule at all: present every day (household default).
patterns = self._patterns.get(child_id) or ()
if any(
pattern.kind == "away" and pattern.covers(target)
for pattern in patterns
):
return False
homes = [pattern for pattern in patterns if pattern.kind == "home"]
if any(pattern.covers(target) for pattern in homes):
return True
week = cycle_week_index(schedule, target)
return target.weekday() in schedule.pattern[week]
return not homes
def next_present_dates(
self,
@@ -442,7 +511,7 @@ class PresenceEngine:
Walks forward from ``start_date`` (INCLUSIVE — a parent
previewing "starting Monday" expects Monday itself when the
child is present) checking :meth:`is_present` for every day,
so overrides and the pattern are honoured identically. The
so overrides and the patterns are honoured identically. The
admin Schedule tab renders this preview so a parent can confirm
a custody pattern is right before saving it.
@@ -2,11 +2,11 @@
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
the pure presence model: callers get the model validation, the create /
update / 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`
:class:`~.presence.PresencePattern` / :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
@@ -15,16 +15,16 @@ 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
Pattern policy (schema 9): a child has ZERO OR MORE presence patterns,
each created, edited and deleted by its own id. Patterns and overrides
are deletable by design (children are not — the children layer owns
that guardrail). An unknown pattern or override id raises ValueError
prefixed ``pattern_id:`` / ``override_id:`` so a caller can distinguish
"unknown id" from a rejected argument the same way the
quest-definitions layer prefixes ``definition_id:``.
The admin plane's presence READS go through here too
(:func:`get_presence_schedule`, :func:`list_presence_overrides`), so a
(:func:`list_presence_patterns`, :func:`list_presence_overrides`), so a
reader never touches the DAO directly either.
"""
from __future__ import annotations
@@ -35,11 +35,13 @@ from .dao_children import ChildrenDao
from .dao_presence import (
PresenceOverrideRecord,
PresenceOverridesDao,
PresenceSchedulesDao,
PresencePatternRecord,
PresencePatternsDao,
)
from .dao_rules import _UNSET
from .db import NestQuestDatabase
from .materialize import count_removed_by_override, regenerate_for_child
from .presence import PresenceOverride, PresenceSchedule
from .presence import PresenceOverride, PresencePattern
def _validate_override_id(value: object) -> int:
@@ -49,49 +51,159 @@ def _validate_override_id(value: object) -> int:
return value
async def set_presence_schedule(
def _validate_pattern_id(value: object) -> int:
"""Reject non-int pattern ids (bools would silently address id 1)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"pattern_id must be an integer, got {value!r}")
return value
async def _get_pattern_or_raise(
dao: PresencePatternsDao, pattern_id: object
) -> PresencePatternRecord:
"""Return the stored pattern; an unknown id raises ``pattern_id:``."""
record = await dao.get(_validate_pattern_id(pattern_id))
if record is None:
raise ValueError(
f"pattern_id: presence pattern {pattern_id} does not exist"
)
return record
def decode_pattern_record(record: PresencePatternRecord) -> PresencePattern:
"""Decode one stored pattern row back through the model."""
return PresencePattern.decode(
record.child_id,
record.name,
record.kind,
record.anchor_date,
record.pattern,
)
async def create_presence_pattern(
database: NestQuestDatabase,
child_id: int,
name: str,
kind: str,
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.
) -> PresencePatternRecord:
"""Add one repeating presence pattern to a child.
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`
The :class:`~.presence.PresencePattern` model validates at
construction — a non-blank name, ``kind`` ``home``/``away``, cycle
length 1..4, a strict calendar anchor date, and a pattern covering
week indices 0..n-1 with weekday sets — so no invalid pattern
reaches the DAO. The encoded pattern is inserted beside the
child's other patterns, 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.
Returns the STORED record WITH its ``id`` — the handle
:func:`update_presence_pattern` and :func:`delete_presence_pattern`
take.
"""
schedule = PresenceSchedule(
child_id, cycle_length_weeks, anchor_date, pattern
model = PresencePattern(
child_id, name, kind, 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(),
record = await PresencePatternsDao(database).create(
model.child_id,
model.name,
model.kind,
model.cycle_length_weeks,
model.anchor_date.isoformat(),
model.encode(),
)
await regenerate_for_child(
database, schedule.child_id, today=today, horizon_days=horizon_days
database, model.child_id, today=today, horizon_days=horizon_days
)
return PresenceSchedule.decode(
record.child_id, record.anchor_date, record.pattern
return record
async def update_presence_pattern(
database: NestQuestDatabase,
pattern_id: int,
*,
name: str | object = _UNSET,
kind: str | object = _UNSET,
cycle_length_weeks: int | object = _UNSET,
anchor_date: object = _UNSET,
pattern: dict[int, object] | object = _UNSET,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresencePatternRecord:
"""Edit one presence pattern; only supplied fields change.
The pattern is looked up FIRST (an unknown id raises ValueError
prefixed ``pattern_id:``), the supplied fields are laid over the
stored ones and the WHOLE result is rebuilt through the
:class:`~.presence.PresencePattern` model — so a partial edit is
validated exactly as a create is (changing ``cycle_length_weeks``
without a matching ``pattern`` is rejected), and an explicit None
is rejected rather than ignored. The pattern stays with its child;
the child's future instances are regenerated afterwards.
"""
dao = PresencePatternsDao(database)
stored = decode_pattern_record(await _get_pattern_or_raise(dao, pattern_id))
model = PresencePattern(
stored.child_id,
stored.name if name is _UNSET else name,
stored.kind if kind is _UNSET else kind,
(
stored.cycle_length_weeks
if cycle_length_weeks is _UNSET
else cycle_length_weeks
),
stored.anchor_date if anchor_date is _UNSET else anchor_date,
dict(stored.pattern) if pattern is _UNSET else pattern,
)
record = await dao.update(
pattern_id,
model.name,
model.kind,
model.cycle_length_weeks,
model.anchor_date.isoformat(),
model.encode(),
)
if record is None:
raise ValueError(
f"pattern_id: presence pattern {pattern_id} does not exist"
)
await regenerate_for_child(
database, model.child_id, today=today, horizon_days=horizon_days
)
return record
async def delete_presence_pattern(
database: NestQuestDatabase,
pattern_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresencePatternRecord:
"""Delete one presence pattern by id and return it as it was stored.
The pattern is looked up FIRST and an unknown id raises ValueError
prefixed ``pattern_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 pattern's own
child — the id decides the child, never a caller-supplied one.
"""
dao = PresencePatternsDao(database)
record = await _get_pattern_or_raise(dao, pattern_id)
await dao.delete(record.id)
await regenerate_for_child(
database, record.child_id, today=today, horizon_days=horizon_days
)
return record
async def create_presence_override(
@@ -171,21 +283,16 @@ async def preview_presence_override_consequence(
)
async def get_presence_schedule(
async def list_presence_patterns(
database: NestQuestDatabase, child_id: int
) -> PresenceSchedule | None:
"""Return the child's presence schedule decoded, or None when unset.
) -> list[PresencePatternRecord]:
"""Return the child's presence patterns ordered by id.
None means the child exists and has no schedule row — present
An empty list means the child exists and has no pattern — present
every day (Feature 05). An unknown child raises ValueError
(``child N does not exist``) rather than reading as present.
"""
record = await PresenceSchedulesDao(database).get_by_child(child_id)
if record is None:
return None
return PresenceSchedule.decode(
record.child_id, record.anchor_date, record.pattern
)
return await PresencePatternsDao(database).list_by_child(child_id)
async def list_presence_overrides(
+24 -14
View File
@@ -43,18 +43,19 @@ rule or a monthly rule could later be pinned to a month) nor extra
fields on weekly/monthly/yearly rules generally, so the CHECKs only
reject what the rule engine definitively cannot express now.
Presence pattern format: ``presence_schedules.pattern`` encodes the
Presence pattern format: ``presence_patterns.pattern`` encodes the
whole N-week repeating cycle in one TEXT column as pipe-separated
segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
week 1. An empty segment means the child is absent every day of that
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means
absent every day of every week, which is distinct from having no
schedule row at all (a child with no schedule is present every day, per
the feature guardrails). Chosen over JSON or per-week rows to stay
consistent with the CSV ``weekday_set`` convention and fully checkable
by plain CHECKs. DB-level validation is pure CHECK, no triggers or
week 1. An empty segment means the pattern covers no day of that week.
A child carries zero or more patterns, each ``kind`` ``home`` or
``away``; a child with no pattern row at all is present every day, per
the feature guardrails (how patterns combine is the presence engine's
job). The encoding and its CHECKs are carried over verbatim from the
retired ``presence_schedules`` table (schema 9). Chosen over JSON or
per-week rows to stay consistent with the CSV ``weekday_set``
convention and fully checkable by plain CHECKs. DB-level validation is pure CHECK, no triggers or
custom functions: char-class GLOBs reject every character outside
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
(double comma, comma next to a pipe, leading/trailing comma) and
@@ -68,7 +69,8 @@ weekday_set this is not full CSV-set normalisation (duplicates like
the engine treats the segment as a set. A
one-row-per-``week_index`` normalization was considered and rejected:
the flat single-column encoding keeps the done-condition's table shape
and proved fully checkable with plain CHECKs.
and proved fully checkable with plain CHECKs. ``name`` is a short
label and carries no CHECK; the model rejects a blank one.
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
common case, and a small cap keeps anchor arithmetic and the encoded
@@ -288,11 +290,19 @@ SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
""",
]
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS presence_schedules (
#: ``presence_patterns`` (schema version 9): zero or more repeating
#: presence rules per child, replacing the retired one-per-child
#: ``presence_schedules`` table. ``child_id`` is deliberately NOT
#: unique; ``kind`` is what a covering pattern claims (``home`` or
#: ``away``); ``pattern`` keeps the retired table's encoding and CHECKs
#: verbatim.
SCHEMA_V9_PRESENCE_PATTERNS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS presence_patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
child_id INTEGER NOT NULL REFERENCES children(id),
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('home', 'away')),
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL CHECK (
@@ -407,7 +417,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V9_PRESENCE_PATTERNS_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
+32 -21
View File
@@ -39,14 +39,14 @@ from typing import Any
from .completion import derive_state
from .dao_children import ChildrenDao
from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .dao_presence import PresenceOverridesDao, PresencePatternsDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
PresencePattern,
)
from .settings import NestQuestSettings
@@ -98,20 +98,26 @@ class NestQuestSnapshot:
def _cycle_day(
schedules: dict[int, PresenceSchedule], children, today: datetime.date
patterns: dict[int, list[PresencePattern]],
children,
today: datetime.date,
) -> int:
"""Return today's 1-based day in the first scheduled child's cycle.
"""Return today's 1-based day in the first patterned child's cycle.
Anchor-date arithmetic ONLY (D-004): the day offset is computed from
the schedule's own anchor, never ISO week numbers or parity, so a
53-week year cannot silently invert the cycle.
The cycle is that child's FIRST pattern (lowest id) — one number
for the household's cycle-day sensor even when a child carries
several patterns. Anchor-date arithmetic ONLY (D-004): the day
offset is computed from the pattern's own anchor, never ISO week
numbers or parity, so a 53-week year cannot silently invert the
cycle.
"""
for child in children:
schedule = schedules.get(child.id)
if schedule is None:
entries = patterns.get(child.id)
if not entries:
continue
days = (today - schedule.anchor_date).days
cycle_length = schedule.cycle_length_weeks * 7
pattern = entries[0]
days = (today - pattern.anchor_date).days
cycle_length = pattern.cycle_length_weeks * 7
return (days % cycle_length) + 1
return 0
@@ -191,16 +197,21 @@ async def build_snapshot(
children = await ChildrenDao(database).list_active()
schedules: dict[int, PresenceSchedule] = {}
patterns: dict[int, list[PresencePattern]] = {}
overrides: dict[int, list[PresenceOverride]] = {}
for child in children:
schedule = await PresenceSchedulesDao(database).get_by_child(
child.id
)
if schedule is not None:
schedules[child.id] = PresenceSchedule.decode(
child.id, schedule.anchor_date, schedule.pattern
)
records = await PresencePatternsDao(database).list_by_child(child.id)
if records:
patterns[child.id] = [
PresencePattern.decode(
record.child_id,
record.name,
record.kind,
record.anchor_date,
record.pattern,
)
for record in records
]
window_end = today + datetime.timedelta(
days=MAX_PREVIEW_SCAN_DAYS
)
@@ -222,7 +233,7 @@ async def build_snapshot(
)
for record in ranged
]
engine = PresenceEngine(schedules, overrides)
engine = PresenceEngine(patterns, overrides)
instances_dao = QuestInstancesDao(database)
events_dao = CompletionEventsDao(database)
@@ -302,5 +313,5 @@ async def build_snapshot(
return NestQuestSnapshot(
today_iso=today_iso,
children=tuple(snapshots),
cycle_day=_cycle_day(schedules, children, today),
cycle_day=_cycle_day(patterns, children, today),
)