Compare commits

...
4 changed files with 3295 additions and 16 deletions
+547 -16
View File
@@ -40,6 +40,7 @@ from .const import QUEST_WINDOWS
from .dao_children import ChildRecord, _child_from_row, _connection_lock
from .dao_children import _CHILD_COLUMNS
from .db import NestQuestDatabase
from .recurrence import RuleType, RuleValidationError, ScheduleRule
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
#: in update methods: passing ``None`` must mean clearing a nullable
@@ -119,6 +120,26 @@ class ScheduleRuleRecord:
end_date: str | None
@dataclass(frozen=True)
class ScheduleRuleStorage:
"""The eight value columns a ScheduleRule maps to (no id column).
``id`` is assigned by the database on insert, so the model<->storage
mapping concerns only the value columns. ``nth_weekday_weekday`` is
deliberately absent here: for MONTHLY_WEEKDAY its value is folded
into ``weekday_set`` as that column's single CSV element.
"""
rule_type: str
interval: int
weekday_set: str | None
day_of_month: int | None
nth_weekday: int | None
month: int | None
start_date: str
end_date: str | None
@dataclass(frozen=True)
class QuestDefinitionRecord:
"""One row of ``quest_definitions`` (without its assignees)."""
@@ -142,6 +163,24 @@ class QuestDefinitionWindowRecord:
due_time: str | None
@dataclass(frozen=True)
class QuestDefinitionSnapshot:
"""A definition plus its rule, assignees and windows, read atomically.
Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows`
and :meth:`QuestDefinitionsDao.edit_definition` with the rule,
assignees and windows read inside the SAME transaction that wrote
them, so the snapshot is a consistent creation/edit-time view — a
concurrent rule, assignee or window mutation cannot change it
between the write and the read.
"""
definition: QuestDefinitionRecord
rule: ScheduleRuleRecord
assignees: list[ChildRecord]
windows: list[QuestDefinitionWindowRecord]
_RULE_COLUMNS = (
"id, rule_type, interval, weekday_set, day_of_month, nth_weekday, "
"month, start_date, end_date"
@@ -188,6 +227,250 @@ def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord:
)
#: Storage columns that are forbidden for each rule type. A populated
#: forbidden column means the row's shape is invalid and must raise on
#: read, never be silently dropped. ``weekday_set`` is absent for
#: DAILY/MONTHLY_DAY/YEARLY; MONTHLY_WEEKDAY is the only shape allowed a
#: non-NULL ``nth_weekday``; YEARLY is the only shape allowed ``month``.
_FORBIDDEN_STORAGE_FIELDS: dict[str, tuple[RuleType, ...]] = {
"weekday_set": (RuleType.DAILY, RuleType.MONTHLY_DAY, RuleType.YEARLY),
"day_of_month": (
RuleType.DAILY,
RuleType.WEEKLY,
RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_WEEKDAY,
),
"nth_weekday": (
RuleType.DAILY,
RuleType.WEEKLY,
RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY,
RuleType.YEARLY,
),
"month": (
RuleType.DAILY,
RuleType.WEEKLY,
RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY,
RuleType.MONTHLY_WEEKDAY,
),
}
def schedule_rule_to_storage(rule: ScheduleRule) -> ScheduleRuleStorage:
"""Map a validated ScheduleRule onto its storage columns.
``rule_type`` stores as ``RuleType.storage_value`` (MONTHLY_DAY and
MONTHLY_WEEKDAY both store ``'monthly'``); ``weekday_set`` stores as
a sorted CSV list. MONTHLY_WEEKDAY folds its model-only
``nth_weekday_weekday`` into ``weekday_set`` as that list's single
element; every other type leaves the column as its own set
(WEEKLY/CUSTOM_DAYS) or NULL.
"""
shape = rule.rule_type
if shape is RuleType.MONTHLY_WEEKDAY:
weekday_set = str(rule.nth_weekday_weekday)
elif shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
weekday_set = ",".join(str(w) for w in sorted(rule.weekday_set))
else:
weekday_set = None
return ScheduleRuleStorage(
rule_type=shape.storage_value,
interval=rule.interval,
weekday_set=weekday_set,
day_of_month=rule.day_of_month,
nth_weekday=rule.nth_weekday,
month=rule.month,
start_date=rule.start_date,
end_date=rule.end_date,
)
def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule:
"""Reconstruct a ScheduleRule from storage columns.
Disambiguates MONTHLY_DAY from MONTHLY_WEEKDAY by which field is
populated, and raises RuleValidationError rather than silently
mis-decoding an ambiguous or invalid shape.
"""
rule_type = storage.rule_type
if rule_type == "daily":
shape = RuleType.DAILY
elif rule_type == "weekly":
shape = RuleType.WEEKLY
elif rule_type == "monthly":
shape = _disambiguate_monthly(storage)
elif rule_type == "yearly":
shape = RuleType.YEARLY
elif rule_type == "custom":
shape = RuleType.CUSTOM_DAYS
else:
raise RuleValidationError(
f"unknown storage rule_type {rule_type!r}"
)
_reject_forbidden_storage_fields(storage, shape)
weekday_set = None
nth_weekday_weekday = None
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
weekday_set = _weekdays_from_csv(storage.weekday_set)
elif shape is RuleType.MONTHLY_WEEKDAY:
nth_weekday_weekday = _single_weekday_from_csv(storage.weekday_set)
return ScheduleRule(
rule_type=shape,
interval=storage.interval,
weekday_set=weekday_set,
day_of_month=storage.day_of_month,
nth_weekday=storage.nth_weekday,
nth_weekday_weekday=nth_weekday_weekday,
month=storage.month,
start_date=storage.start_date,
end_date=storage.end_date,
)
def schedule_rule_storage_from_record(
record: ScheduleRuleRecord,
) -> ScheduleRuleStorage:
"""Copy a rule row's value columns into a storage mapping.
``ScheduleRuleRecord`` is the read shape (it carries ``id``);
:func:`schedule_rule_from_storage` consumes the value-only
:class:`ScheduleRuleStorage` shape, so callers re-reading a stored
rule convert through this helper.
"""
return ScheduleRuleStorage(
rule_type=record.rule_type,
interval=record.interval,
weekday_set=record.weekday_set,
day_of_month=record.day_of_month,
nth_weekday=record.nth_weekday,
month=record.month,
start_date=record.start_date,
end_date=record.end_date,
)
def _reject_forbidden_storage_fields(
storage: ScheduleRuleStorage, shape: RuleType
) -> None:
"""Raise unless every populated column is permitted for ``shape``.
The model constructor rejects a forbidden ``day_of_month``,
``nth_weekday``, or ``month``, but ``weekday_set`` never reaches it
(it is folded for MONTHLY_WEEKDAY and dropped otherwise), so a daily
row carrying ``weekday_set="0"`` would otherwise decode as a valid
daily rule and silently lose the stored data. This validates every
shape column explicitly so a populated-forbidden column raises
instead of being discarded.
"""
for field, forbidden_shapes in _FORBIDDEN_STORAGE_FIELDS.items():
value = getattr(storage, field)
if value is not None and shape in forbidden_shapes:
raise RuleValidationError(
f"{shape.value} storage rows must not set {field}, "
f"got {value!r}"
)
def _disambiguate_monthly(storage: ScheduleRuleStorage) -> RuleType:
"""Resolve the shared 'monthly' storage value to a model type.
MONTHLY_DAY names ``day_of_month`` only; MONTHLY_WEEKDAY names
``nth_weekday`` only. Neither, or both, is ambiguous and raises.
"""
day_of_month = storage.day_of_month
nth_weekday = storage.nth_weekday
if day_of_month is not None and nth_weekday is None:
return RuleType.MONTHLY_DAY
if nth_weekday is not None and day_of_month is None:
return RuleType.MONTHLY_WEEKDAY
raise RuleValidationError(
"storage rule_type 'monthly' is ambiguous: must name exactly one "
"of day_of_month or nth_weekday, got "
f"day_of_month={day_of_month!r}, nth_weekday={nth_weekday!r}"
)
def _weekdays_from_csv(value: str | None) -> frozenset[int]:
"""Parse a weekday CSV column into a frozenset of ints.
``None`` and ``""`` both yield an empty frozenset (the caller decides
whether empty is legal); a non-integer token raises
RuleValidationError rather than leaking a raw ValueError.
"""
if value is None or value == "":
return frozenset()
try:
entries = [int(token) for token in value.split(",")]
except ValueError:
raise RuleValidationError(
f"weekday_set must be a CSV of integers, got {value!r}"
) from None
return frozenset(entries)
def _single_weekday_from_csv(value: str | None) -> int:
"""The one weekday a MONTHLY_WEEKDAY stores in weekday_set.
Parses the CSV into tokens FIRST and requires exactly one token,
so a duplicate pair like ``"1,1"`` is rejected rather than silently
canonicalized to a single weekday on re-storage. An empty or
multi-element column raises RuleValidationError.
"""
if value is None or value == "":
raise RuleValidationError(
"MONTHLY_WEEKDAY storage weekday_set must hold exactly one "
f"weekday, got {value!r}"
)
tokens = value.split(",")
if len(tokens) != 1:
raise RuleValidationError(
"MONTHLY_WEEKDAY storage weekday_set must hold exactly one "
f"weekday, got {value!r}"
)
try:
return int(tokens[0])
except ValueError:
raise RuleValidationError(
"MONTHLY_WEEKDAY storage weekday_set must be an integer, "
f"got {value!r}"
) from None
async def _insert_rule_row(
database: NestQuestDatabase, storage: ScheduleRuleStorage
) -> int:
"""Insert one ``schedule_rules`` row and return its new id.
Runs inside the caller's transaction (no lock or transaction of its
own) so a multi-table write can persist the rule together with the
rows that reference it. The caller is responsible for validating
the storage fields — :meth:`ScheduleRulesDao.create` validates the
date shape, and :meth:`QuestDefinitionsDao.create_with_rule_and_windows`
receives storage already mapped from a validated
:class:`~.recurrence.ScheduleRule`.
"""
result = await database.execute(
"INSERT INTO schedule_rules (rule_type, interval, weekday_set, "
"day_of_month, nth_weekday, month, start_date, end_date) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
storage.rule_type,
storage.interval,
storage.weekday_set,
storage.day_of_month,
storage.nth_weekday,
storage.month,
storage.start_date,
storage.end_date,
),
)
return result.lastrowid
class ScheduleRulesDao:
"""Typed async access to the ``schedule_rules`` table."""
@@ -218,22 +501,20 @@ class ScheduleRulesDao:
_validate_date(start_date, "start_date")
if end_date is not None:
_validate_date(end_date, "end_date")
result = await self._database.execute(
"INSERT INTO schedule_rules (rule_type, interval, weekday_set, "
"day_of_month, nth_weekday, month, start_date, end_date) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
rule_type,
interval,
weekday_set,
day_of_month,
nth_weekday,
month,
start_date,
end_date,
rule_id = await _insert_rule_row(
self._database,
ScheduleRuleStorage(
rule_type=rule_type,
interval=interval,
weekday_set=weekday_set,
day_of_month=day_of_month,
nth_weekday=nth_weekday,
month=month,
start_date=start_date,
end_date=end_date,
),
)
rule = await self.get(result.lastrowid)
rule = await self.get(rule_id)
assert rule is not None
return rule
@@ -393,6 +674,191 @@ class QuestDefinitionsDao:
assert definition is not None
return definition
async def create_with_rule_and_windows(
self,
title: str,
schedule_rule: ScheduleRuleStorage,
created_at: str,
assignee_child_ids: list[int],
windows: list[tuple[str, str | None]],
*,
description: str | None = None,
icon: str | None = None,
) -> QuestDefinitionSnapshot:
"""Insert a rule, definition, assignees and windows atomically.
The whole write — the schedule rule row, the definition row,
every assignee link and every window declaration — runs inside
ONE transaction under the connection lock, so a validation or
write failure rolls the lot back together (all-or-nothing): the
caller can never observe a definition with a missing rule,
assignee or window, and a rejected create leaves no partial
rows.
Returns a :class:`QuestDefinitionSnapshot` whose assignees and
windows are read back inside the same transaction, so the
returned view cannot be raced by a concurrent assignee/window
mutation after the write.
The rule arrives as pre-validated storage fields (mapped from a
:class:`~.recurrence.ScheduleRule` via
:func:`schedule_rule_to_storage`); its dates are re-validated
here so a hand-built storage can never persist a malformed date.
The definition-level ``due_time`` column is deliberately left
NULL — per-window due times supersede it (D-008). Assignees are
validated as existing-and-active at insert time, and windows are
validated for name and strict HH:MM due time, matching
:meth:`upsert_window`.
"""
_validate_date(schedule_rule.start_date, "start_date")
if schedule_rule.end_date is not None:
_validate_date(schedule_rule.end_date, "end_date")
async with _connection_lock(self._database):
async with self._database.transaction():
for child_id in assignee_child_ids:
await self._validate_assignable_child(child_id)
rule_id = await _insert_rule_row(self._database, schedule_rule)
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(title, description, icon, rule_id, None, 1, created_at),
)
definition_id = result.lastrowid
for child_id in assignee_child_ids:
await self._database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?)",
(definition_id, child_id),
)
for window, due_time in windows:
_validate_window(window)
if due_time is not None:
_validate_time(due_time, "due_time")
await self._database.execute(
"INSERT INTO quest_definition_windows "
"(definition_id, window, due_time) VALUES (?, ?, ?)",
(definition_id, window, due_time),
)
definition = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
rule_record = await ScheduleRulesDao(self._database).get(
rule_id
)
assert definition is not None
assert rule_record is not None
return QuestDefinitionSnapshot(
definition=definition,
rule=rule_record,
assignees=assignees,
windows=window_records,
)
async def edit_definition(
self,
definition_id: int,
*,
title: str | None | object = _UNSET,
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
rule: ScheduleRuleStorage | object = _UNSET,
windows: list[tuple[str, str | None]] | object = _UNSET,
) -> QuestDefinitionSnapshot:
"""Edit a definition's metadata, rule and windows atomically.
Mirrors :meth:`create_with_rule_and_windows`: everything runs
inside ONE transaction under the connection lock, and the
returned snapshot's rule, assignees and windows are read back
inside that transaction. Arguments default to the module sentinel
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
NULL so optional metadata (description, icon) can be cleared.
- ``title``/``description``/``icon`` update the definition row
in place; the definition-level ``due_time`` column is never
written (per-window due times supersede it, D-008).
- ``rule`` (a pre-validated :class:`ScheduleRuleStorage`)
overwrites the referenced ``schedule_rules`` row in place: the
definition keeps its ``schedule_rule_id``, so no new or
orphaned rule row appears.
- ``windows`` REPLACES the whole window set: the current rows
are deleted and the given set re-inserted, so missing windows
are added, changed due times updated and removed windows
dropped.
Assignment is deliberately not settable here (multi-assignee is
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
activation is untouched. Raises ValueError when the definition
does not exist. Edits change future instances only: no
``quest_instances`` or ``completion_events`` row is touched.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definition = await self.get(definition_id)
if definition is None:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
assignments: list[str] = []
parameters: list[object] = []
for column, value in (
("title", title),
("description", description),
("icon", icon),
):
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if assignments:
parameters.append(definition_id)
await self._database.execute(
f"UPDATE quest_definitions SET "
f"{', '.join(assignments)} WHERE id = ?",
tuple(parameters),
)
if rule is not _UNSET:
await ScheduleRulesDao(self._database).update(
definition.schedule_rule_id,
rule_type=rule.rule_type,
interval=rule.interval,
weekday_set=rule.weekday_set,
day_of_month=rule.day_of_month,
nth_weekday=rule.nth_weekday,
month=rule.month,
start_date=rule.start_date,
end_date=rule.end_date,
)
if windows is not _UNSET:
await self._database.execute(
"DELETE FROM quest_definition_windows "
"WHERE definition_id = ?",
(definition_id,),
)
for window, due_time in windows:
_validate_window(window)
if due_time is not None:
_validate_time(due_time, "due_time")
await self._database.execute(
"INSERT INTO quest_definition_windows "
"(definition_id, window, due_time) "
"VALUES (?, ?, ?)",
(definition_id, window, due_time),
)
updated = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
rule_record = await ScheduleRulesDao(self._database).get(
updated.schedule_rule_id
)
assert updated is not None
assert rule_record is not None
return QuestDefinitionSnapshot(
definition=updated,
rule=rule_record,
assignees=assignees,
windows=window_records,
)
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
"""Raise ValueError unless the rule exists.
@@ -441,6 +907,66 @@ class QuestDefinitionsDao:
)
return [_definition_from_row(row) for row in rows]
async def _snapshots_for(
self, definitions: list[QuestDefinitionRecord]
) -> list[QuestDefinitionSnapshot]:
"""Read each definition's rule, assignees and windows.
MUST be called inside the connection lock (and ideally a
transaction) so the definitions handed in and the rows this
reads back cannot be raced by a concurrent edit between list
and bundle. Each rule is read through
:class:`ScheduleRulesDao` on the same connection, so the
snapshot's rule record is the one the definition references AT
the moment of the locked read.
"""
rules_dao = ScheduleRulesDao(self._database)
snapshots: list[QuestDefinitionSnapshot] = []
for definition in definitions:
rule_record = await rules_dao.get(definition.schedule_rule_id)
assert rule_record is not None
snapshots.append(
QuestDefinitionSnapshot(
definition=definition,
rule=rule_record,
assignees=await self.list_assignees(definition.id),
windows=await self.list_windows(definition.id),
)
)
return snapshots
async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition with its rule, assignees and
windows, all read inside one locked transaction.
The definitive list and each definition's linked rows are read
in a single BEGIN..COMMIT span under the connection lock, so a
concurrent atomic edit cannot interleave between the definition
list and the bundle read: the caller receives a coherent
snapshot (each definition's rule, assignees and windows as they
were at one instant), never a mixed old-rule/new-windows state.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def list_snapshots_by_child(
self, child_id: int
) -> list[QuestDefinitionSnapshot]:
"""Return each definition assigned to ``child_id`` with its rule,
assignees and windows, all read inside one locked transaction.
Mirrors :meth:`list_snapshots_active` but selects by assignment
(``list_by_child`` order: newest first). Locks read-and-bundle
into one transaction so the bundle never mixes one edit's
definition with another's windows.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_by_child(child_id)
return await self._snapshots_for(definitions)
async def update(
self,
definition_id: int,
@@ -496,14 +1022,18 @@ class QuestDefinitionsDao:
async def add_assignee(
self, definition_id: int, child_id: int
) -> None:
) -> list[ChildRecord]:
"""Assign ``child_id`` to the definition (idempotent).
Validates the assignee inside the same serialized transaction
as the INSERT (existing and active AT ASSIGNMENT TIME), so a
child deactivated concurrently cannot become an assignee.
Assigning an already-assigned child is a no-op, mirroring the
composite primary key's storage-level guarantee. Changes
composite primary key's storage-level guarantee. Returns the
definition's assignees read back INSIDE the same transaction
that performed the write, so the returned roster is a
consistent post-assignment snapshot — a concurrent assign or
unassign cannot race it after the write commits. Changes
future instances only: already-generated instances and
completion history are never rewritten here.
"""
@@ -524,6 +1054,7 @@ class QuestDefinitionsDao:
"ON CONFLICT (definition_id, child_id) DO NOTHING",
(definition_id, child_id),
)
return await self.list_assignees(definition_id)
async def remove_assignee(
self, definition_id: int, child_id: int
@@ -0,0 +1,551 @@
"""Business layer for creating and editing quest definitions (Feature 06).
Sits between the Feature 09 service gate and the typed
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path,
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
Validation policy mirrors :mod:`.children`:
- ``title`` is required and trimmed; empty or whitespace-only raises
ValueError.
- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
(its constructor is total — rule validation is NOT re-implemented
here).
- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``);
every id is a plain int (bools and floats rejected, since SQLite would
bind them onto a real child) and must reference an existing ACTIVE
child (the DAO enforces the active/exists check inside the same
transaction). Duplicate ids are rejected here, before the composite
primary key could surface them as an :class:`sqlite3.IntegrityError`.
- ``windows`` must be a non-empty list (strictly a ``list``); each entry
is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare
name (no due time) or a ``(name, due_time)`` pair whose due time is a
strict 24-hour HH:MM string. Duplicate window names are rejected
here, before the composite primary key could surface them as an
:class:`sqlite3.IntegrityError`.
The rule, definition, assignees and windows are persisted in ONE
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
so a rejected or failed create persists nothing. The definition-level
``due_time`` column is superseded by per-window due times (D-008) and is
never set here. ``created_at`` is stamped UTC ISO-8601 at second
precision, matching :mod:`.children`.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import ChildRecord, _connection_lock
from .dao_rules import (
QuestDefinitionRecord,
QuestDefinitionSnapshot,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
ScheduleRuleStorage,
ScheduleRulesDao,
_UNSET,
_validate_date,
_validate_time,
_validate_window,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
from .recurrence import ScheduleRule, occurs_on
@dataclass(frozen=True)
class CreatedQuestDefinition:
"""The persisted result of a create, edit or activation change.
``definition`` is the stored row; ``rule`` is the schedule rule
decoded back through the storage mapping; ``assignees`` and
``windows`` are the definition's linked child records and window
declarations, read through the DAO.
"""
definition: QuestDefinitionRecord
rule: ScheduleRule
assignees: list[ChildRecord]
windows: list[QuestDefinitionWindowRecord]
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_text(
value: object, field: str, *, required: bool = False
) -> str | None:
"""Validate a free-text field and return it trimmed.
``None`` raises when ``required`` and otherwise returns None. A
non-string value is rejected; an empty result is rejected for
required fields.
"""
if value is None:
if required:
raise ValueError(f"{field} is required")
return None
if not isinstance(value, str):
raise ValueError(f"{field} must be a string, got {value!r}")
trimmed = value.strip()
if required and not trimmed:
raise ValueError(f"{field} is required")
return trimmed
def _validate_assignee_ids(value: object) -> list[int]:
"""Return the assignee ids as plain ints, or raise naming the field.
The argument must be a non-empty list — strictly a ``list``: tuples
and other sequences are rejected. Each entry must be a real int
(bools and floats rejected) so SQLite cannot bind ``True`` onto
child 1 or a float onto a neighbouring profile.
"""
if type(value) is not list:
raise ValueError(
f"assignee_child_ids must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("assignee_child_ids must not be empty")
ids: list[int] = []
for entry in value:
if isinstance(entry, bool) or not isinstance(entry, int):
raise ValueError(
"assignee_child_ids must contain integer child ids, "
f"got {entry!r}"
)
ids.append(entry)
if len(set(ids)) != len(ids):
raise ValueError(
"assignee_child_ids must not contain duplicate child ids"
)
return ids
def _normalize_windows(
value: object,
) -> list[tuple[str, str | None]]:
"""Normalize the windows argument to ``(name, due_time)`` pairs.
The outer argument must be a non-empty list — strictly a ``list``:
tuples and other sequences are rejected, while each individual entry
may still be a ``(window, due_time)`` tuple. A bare window name
(due time None) is also accepted. The name must be a
:data:`~.const.QUEST_WINDOWS` spelling and a present due time must be
a strict 24-hour HH:MM, both validated up front so a malformed
window is rejected before any write.
"""
if type(value) is not list:
raise ValueError(
f"windows must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("windows must not be empty")
normalized: list[tuple[str, str | None]] = []
seen: set[str] = set()
for index, entry in enumerate(value):
if isinstance(entry, str):
name: object = entry
due_time: object = None
elif isinstance(entry, (tuple, list)) and len(entry) == 2:
name, due_time = entry[0], entry[1]
else:
raise ValueError(
f"windows[{index}] must be a window name or a "
f"(name, due_time) pair, got {entry!r}"
)
_validate_window(name)
if due_time is not None:
_validate_time(due_time, "due_time")
if name in seen:
raise ValueError(
f"windows must not contain duplicate window {name!r}"
)
seen.add(name)
normalized.append((name, due_time))
return normalized
def _validate_definition_id(value: object) -> int:
"""Reject non-int definition ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address definition 1 and a float would round — malformed service
input must never mutate the wrong definition.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"definition_id must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def create_quest_definition(
database: NestQuestDatabase,
title: str,
rule: ScheduleRule,
assignee_child_ids: list[int],
windows: list[str | tuple[str, str | None]],
*,
description: str | None = None,
icon: str | None = None,
) -> CreatedQuestDefinition:
"""Create a quest definition (rule + assignees + windows) atomically.
Validates the arguments up front, then persists the schedule rule,
the definition, its assignees and its windows in one transaction via
the DAO. Returns the stored definition together with its decoded
rule, assignees and windows — all read back inside that same
transaction, so the returned bundle is a consistent creation
snapshot. Raises ValueError on any rejected argument; a rejected or
failed create persists nothing.
"""
name = _validate_text(title, "title", required=True)
assert name is not None
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
assignee_ids = _validate_assignee_ids(assignee_child_ids)
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
storage = schedule_rule_to_storage(rule)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.create_with_rule_and_windows(
name,
storage,
_now_stamp(),
assignee_ids,
window_specs,
description=description_value,
icon=icon_value,
)
except ValueError as error:
# The DAO's assignee check names only the child; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(
f"assignee_child_ids: {message}"
) from error
raise
decoded_rule = schedule_rule_from_storage(storage)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def edit_quest_definition(
database: NestQuestDatabase,
definition_id: int,
*,
title: str | object = _UNSET,
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule and windows atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
(required and trimmed); ``description`` and ``icon`` are optional
and may be cleared by passing ``None`` explicitly. A provided
``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
and REPLACES the definition's schedule rule IN PLACE — the existing
``schedule_rules`` row is updated, never orphaned or duplicated. A
provided ``windows`` list REPLACES the whole window set (each entry
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
so the returned bundle is a consistent edit snapshot. Edits change
future instances only; no ``quest_instances`` or
``completion_events`` row is touched.
"""
_validate_definition_id(definition_id)
title_value: str | None | object = _UNSET
if title is not _UNSET:
name = _validate_text(title, "title", required=True)
assert name is not None
title_value = name
description_value: str | None | object = _UNSET
if description is not _UNSET:
description_value = _validate_text(description, "description")
icon_value: str | None | object = _UNSET
if icon is not _UNSET:
icon_value = _validate_text(icon, "icon")
rule_storage: ScheduleRuleStorage | object = _UNSET
if rule is not _UNSET:
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
rule_storage = schedule_rule_to_storage(rule)
window_specs: list[tuple[str, str | None]] | object = _UNSET
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.edit_definition(
definition_id,
title=title_value,
description=description_value,
icon=icon_value,
rule=rule_storage,
windows=window_specs,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def assign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
) -> list[ChildRecord]:
"""Assign ``child_id`` to the definition (idempotent).
Validates that both ids are plain ints (bools and floats rejected)
and that the child exists and is active — the DAO enforces the
active/exists check inside its transaction, and also confirms the
definition exists. Assigning an already-assigned child is a no-op
(no duplicate row). Returns the definition's assignees read back
inside the DAO's same transaction, a consistent post-assignment
roster (a concurrent assign/unassign cannot race it after the
write). Raises ValueError on any rejected argument. Only the
``quest_definition_assignees`` link is written; existing instances
and completion history are never touched.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
try:
return await dao.add_assignee(definition_id, child_id)
except ValueError as error:
# The DAO names only the child/definition; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(f"child_id: {message}") from error
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
async def unassign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
) -> bool:
"""Unassign ``child_id`` from the definition; True when removed.
Validates both ids are plain ints (bools and floats rejected) and
that the definition exists, then removes the (definition, child)
link. Returns True when a row was removed and False when the child
was not currently assigned. Raises ValueError on a rejected
argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched.
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
definition = await dao.get(definition_id)
if definition is None:
raise ValueError(
f"definition_id: quest definition {definition_id} does not exist"
)
return await dao.remove_assignee(definition_id, child_id)
async def set_quest_definition_active(
database: NestQuestDatabase,
definition_id: int,
is_active: bool,
) -> CreatedQuestDefinition:
"""Deactivate or reactivate a definition; returns the updated view.
Flipping the flag is the only removal path (Feature 06 guardrail):
a deactivated definition stops future instance generation but its
row, schedule rule, assignees and windows all survive, and existing
instances plus completion history are never touched. Reactivation
re-enables future generation; generating or removing instances is
Feature 07 and does not happen here.
``definition_id`` must be a plain int (bools and floats rejected,
since SQLite would bind ``True`` onto definition 1) and
``is_active`` must be a real bool — the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError naming the
offending field when the id is malformed, ``is_active`` is not a
bool, or the definition does not exist.
Atomicity: the existence check, the UPDATE and the readback of the
definition, its rule, assignees and windows all run inside the
connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race.
"""
_validate_definition_id(definition_id)
if not isinstance(is_active, bool):
raise ValueError(f"is_active must be a real bool, got {is_active!r}")
async with _connection_lock(database):
dao = QuestDefinitionsDao(database)
if await dao.get(definition_id) is None:
raise ValueError(
f"definition_id: quest definition {definition_id} "
"does not exist"
)
await dao.set_active(definition_id, is_active)
updated = await dao.get(definition_id)
assert updated is not None
rule_record = await ScheduleRulesDao(database).get(
updated.schedule_rule_id
)
assert rule_record is not None
assignees = await dao.list_assignees(definition_id)
windows = await dao.list_windows(definition_id)
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(rule_record)
)
return CreatedQuestDefinition(
definition=updated,
rule=rule,
assignees=assignees,
windows=windows,
)
def _bundle_snapshot(
snapshot: QuestDefinitionSnapshot,
) -> CreatedQuestDefinition:
"""Decode a DAO snapshot into the layer's rich view.
The rule record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
coherence the DAO's locked snapshot guarantees: the rule, assignees
and windows all come from one atomic read, never three separate ones.
"""
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def list_active_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return every ACTIVE definition with its decoded rule and links.
Ordered by rising definition id, matching the DAO's ``list_active``
order, so callers get a stable, repeatable sequence. Each entry is
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
the layer: the stored row plus its decoded
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
definitions and their links are read by the DAO inside one locked
transaction, so each bundle is a coherent snapshot that a concurrent
edit cannot interleave.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
) -> list[CreatedQuestDefinition]:
"""Return the definitions assigned to ``child_id`` (rich view).
``child_id`` must be a plain int (bools and floats rejected, since
SQLite would bind ``True`` onto child 1). Results are ordered by
rising definition id, matching ``list_active_definitions`` and
``list_definitions_firing_on``. Each entry is a
:class:`CreatedQuestDefinition` snapshot bundling the definition with
its decoded rule, assignees and windows, read atomically by the DAO.
"""
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_by_child(child_id)
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return sorted(bundles, key=lambda bundle: bundle.definition.id)
async def list_definitions_firing_on(
database: NestQuestDatabase,
date: str,
) -> list[CreatedQuestDefinition]:
"""Return the ACTIVE definitions whose rule fires on ``date``.
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
value raises ValueError naming the field. Each active definition's
stored rule is decoded through the task-1 storage mapping and handed
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
definitions whose decoded rule fires on the date are returned, in
rising definition-id order, each as the same rich
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
The definitions and their rules are read inside one locked
transaction, so a bundle can never filter on one rule while
presenting another state's windows.
"""
_validate_date(date, "date")
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
+251
View File
@@ -9,13 +9,21 @@ import pytest
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_rules import (
ScheduleRuleRecord,
ScheduleRuleStorage,
ScheduleRulesDao,
QuestDefinitionRecord,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
schedule_rule_from_storage,
schedule_rule_to_storage,
)
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
)
def _run(coro):
@@ -423,6 +431,249 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
assert create_won is False
# ---------------------------------------------------------------------------
# ScheduleRule <-> storage mapping
# ---------------------------------------------------------------------------
_RULE_STORAGE_CASES = [
(
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"),
ScheduleRuleStorage(
"daily", 1, None, None, None, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.DAILY,
interval=3,
start_date="2026-09-01",
end_date="2026-12-31",
),
ScheduleRuleStorage(
"daily", 3, None, None, None, None, "2026-09-01", "2026-12-31"
),
),
(
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={2, 0, 4},
start_date="2026-09-01",
),
ScheduleRuleStorage(
"weekly", 1, "0,2,4", None, None, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS,
weekday_set={1, 3, 5},
interval=2,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"custom", 2, "1,3,5", None, None, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.MONTHLY_DAY,
day_of_month=15,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"monthly", 1, None, 15, None, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.MONTHLY_DAY,
day_of_month=31,
interval=3,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"monthly", 3, None, 31, None, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY,
nth_weekday=2,
nth_weekday_weekday=1,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"monthly", 1, "1", None, 2, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY,
nth_weekday=-1,
nth_weekday_weekday=4,
interval=2,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"monthly", 2, "4", None, -1, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01"
),
ScheduleRuleStorage(
"yearly", 1, None, None, None, 6, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.YEARLY,
month=2,
day_of_month=29,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"yearly", 1, None, 29, None, 2, "2026-09-01", None
),
),
]
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
def test_schedule_rule_to_storage_maps_each_type(rule, storage) -> None:
assert schedule_rule_to_storage(rule) == storage
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
def test_schedule_rule_from_storage_rebuilds_each_type(rule, storage) -> None:
assert schedule_rule_from_storage(storage) == rule
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
def test_schedule_rule_round_trips_losslessly_both_directions(
rule, storage
) -> None:
assert schedule_rule_from_storage(schedule_rule_to_storage(rule)) == rule
assert schedule_rule_to_storage(schedule_rule_from_storage(storage)) == storage
@pytest.mark.parametrize(
"storage",
[
# monthly with neither day_of_month nor nth_weekday
ScheduleRuleStorage(
"monthly", 1, None, None, None, None, "2026-09-01", None
),
# monthly with both day_of_month and nth_weekday
ScheduleRuleStorage(
"monthly", 1, None, 15, 2, None, "2026-09-01", None
),
# MONTHLY_WEEKDAY whose weekday_set is None
ScheduleRuleStorage(
"monthly", 1, None, None, 2, None, "2026-09-01", None
),
# MONTHLY_WEEKDAY whose weekday_set is empty
ScheduleRuleStorage("monthly", 1, "", None, 2, None, "2026-09-01", None),
# MONTHLY_WEEKDAY whose weekday_set is multi-element
ScheduleRuleStorage(
"monthly", 1, "1,3", None, 2, None, "2026-09-01", None
),
# unknown storage rule_type
ScheduleRuleStorage(
"sometimes", 1, None, None, None, None, "2026-09-01", None
),
],
)
def test_schedule_rule_from_storage_rejects_ambiguous_or_invalid(
storage,
) -> None:
with pytest.raises(RuleValidationError):
schedule_rule_from_storage(storage)
def test_schedule_rule_from_storage_rejects_malformed_weekday_csv() -> None:
with pytest.raises(RuleValidationError):
schedule_rule_from_storage(
ScheduleRuleStorage(
"weekly", 1, "0,2,x", None, None, None, "2026-09-01", None
)
)
with pytest.raises(RuleValidationError):
schedule_rule_from_storage(
ScheduleRuleStorage(
"weekly", 1, "7", None, None, None, "2026-09-01", None
)
)
@pytest.mark.parametrize(
"storage",
[
# DAILY carrying shape columns it must not hold.
ScheduleRuleStorage(
"daily", 1, "0", None, None, None, "2026-09-01", None
),
ScheduleRuleStorage(
"daily", 1, None, 15, None, None, "2026-09-01", None
),
ScheduleRuleStorage(
"daily", 1, None, None, 2, None, "2026-09-01", None
),
ScheduleRuleStorage(
"daily", 1, None, None, None, 6, "2026-09-01", None
),
# WEEKLY carrying shape columns it must not hold.
ScheduleRuleStorage(
"weekly", 1, "0,2", 15, None, None, "2026-09-01", None
),
ScheduleRuleStorage(
"weekly", 1, "0,2", None, None, 6, "2026-09-01", None
),
# CUSTOM_DAYS carrying shape columns it must not hold.
ScheduleRuleStorage(
"custom", 1, "0,2", 15, None, None, "2026-09-01", None
),
ScheduleRuleStorage(
"custom", 1, "0,2", None, None, 6, "2026-09-01", None
),
# MONTHLY_DAY carrying weekday_set (folded-only column) or month.
ScheduleRuleStorage(
"monthly", 1, "1", 15, None, None, "2026-09-01", None
),
ScheduleRuleStorage(
"monthly", 1, None, 15, None, 6, "2026-09-01", None
),
# MONTHLY_WEEKDAY carrying a forbidden month.
ScheduleRuleStorage(
"monthly", 1, "1", None, 2, 6, "2026-09-01", None
),
# YEARLY carrying weekday_set or nth_weekday.
ScheduleRuleStorage(
"yearly", 1, "0", None, None, 6, "2026-09-01", None
),
ScheduleRuleStorage(
"yearly", 1, None, None, 2, 6, "2026-09-01", None
),
],
)
def test_schedule_rule_from_storage_rejects_forbidden_fields(storage) -> None:
with pytest.raises(RuleValidationError):
schedule_rule_from_storage(storage)
def test_schedule_rule_from_storage_rejects_duplicate_weekday_token() -> None:
"""A MONTHLY_WEEKDAY with a duplicate CSV token ("1,1") must raise,
never silently canonicalize to a single weekday on re-storage."""
with pytest.raises(RuleValidationError):
schedule_rule_from_storage(
ScheduleRuleStorage(
"monthly", 1, "1,1", None, 2, None, "2026-09-01", None
)
)
# ---------------------------------------------------------------------------
# quest_definitions: create / get
# ---------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff