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:
@@ -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
|
||||
@@ -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,),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user