1132 lines
44 KiB
Python
1132 lines
44 KiB
Python
"""Typed DAO layer for the schedule_rules, quest_definitions and
|
|
quest_definition_assignees tables.
|
|
|
|
All SQL for ``schedule_rules``, ``quest_definitions`` and
|
|
``quest_definition_assignees`` lives in this module per the feature
|
|
guardrails: callers get typed dataclasses back and never see raw rows
|
|
or SQL. Every method is async and runs through
|
|
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
|
executor and the event loop never blocks.
|
|
|
|
Guardrail mapping:
|
|
|
|
- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still
|
|
referenced by any quest definition is rejected (the done-condition),
|
|
not cascaded.
|
|
- ``quest_definitions`` has no delete path at all: definitions are
|
|
deactivated, never hard-deleted, and history must survive.
|
|
- Assignment is multi-assignee (D-008): :meth:`add_assignee` and
|
|
:meth:`remove_assignee` change future instances only — the DAO writes
|
|
the assignee table and nothing else; assignment edits never rewrite
|
|
existing instances or completion history (Feature 06 guardrail).
|
|
- Validation of rule well-formedness and child activeness lives partly
|
|
in the schema CHECKs (rule-type coherence) and partly in
|
|
:meth:`QuestDefinitionsDao.create` (rule existence) and
|
|
:meth:`QuestDefinitionsDao.add_assignee` (active child); no
|
|
permission checks here, that is the Feature 09 gate's job.
|
|
|
|
Concurrency: ``delete_rule_if_unreferenced`` and the assignee
|
|
mutations perform their checks and writes inside one transaction under
|
|
the connection-scoped lock shared with :mod:`.dao_children`, so a
|
|
definition referencing the rule (or an assignee appearing) cannot slip
|
|
in between check and write on this connection.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
from dataclasses import dataclass
|
|
|
|
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
|
|
#: column, not skipping it.
|
|
_UNSET = object()
|
|
|
|
#: Date-shape policy: schedule-rule dates are ISO YYYY-MM-DD. The
|
|
#: schema's lexical end>=start CHECK cannot prove a value is a real
|
|
#: calendar date, so the DAO validates the shape it accepts.
|
|
_DATE_FORMAT = "%Y-%m-%d"
|
|
|
|
|
|
def _validate_date(value: str, field: str) -> None:
|
|
"""Raise ValueError unless ``value`` is a YYYY-MM-DD calendar date.
|
|
|
|
``datetime.date.fromisoformat`` accepts ISO dates but is lenient on
|
|
some builds (e.g. '2026-9-4'); strptime with an exact format is
|
|
strict, and strptime round-trips the parsed value to reject zero
|
|
padding ('2026-9-4' parses but its isoformat differs).
|
|
"""
|
|
try:
|
|
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
|
except (TypeError, ValueError):
|
|
raise ValueError(
|
|
f"{field} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
|
) from None
|
|
if parsed.isoformat() != value:
|
|
raise ValueError(
|
|
f"{field} must be a strict YYYY-MM-DD date, got {value!r}"
|
|
)
|
|
|
|
|
|
_TIME_FORMAT = "%H:%M"
|
|
|
|
|
|
def _validate_time(value: str, field: str) -> None:
|
|
"""Raise ValueError unless ``value`` is a strict 24-hour HH:MM.
|
|
|
|
Same strictness policy as _validate_date: parse with the exact
|
|
format, then round-trip so '9:30' (non-padded) is rejected. Time
|
|
shapes are caller-side policy (the schema stores TEXT): windows'
|
|
optional ``due_time`` and definition ``due_time`` both use this.
|
|
"""
|
|
try:
|
|
parsed = datetime.datetime.strptime(value, _TIME_FORMAT).time()
|
|
except (TypeError, ValueError):
|
|
raise ValueError(
|
|
f"{field} must be a 24-hour HH:MM time, got {value!r}"
|
|
) from None
|
|
if parsed.strftime(_TIME_FORMAT) != value:
|
|
raise ValueError(
|
|
f"{field} must be a strict HH:MM time, got {value!r}"
|
|
)
|
|
|
|
|
|
def _validate_window(window: str) -> None:
|
|
"""Raise ValueError unless ``window`` is a ``const`` window name."""
|
|
if window not in QUEST_WINDOWS:
|
|
raise ValueError(
|
|
f"window must be one of {', '.join(QUEST_WINDOWS)}, "
|
|
f"got {window!r}"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScheduleRuleRecord:
|
|
"""One row of ``schedule_rules``."""
|
|
|
|
id: int
|
|
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 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)."""
|
|
|
|
id: int
|
|
title: str
|
|
description: str | None
|
|
icon: str | None
|
|
schedule_rule_id: int
|
|
due_time: str | None
|
|
is_active: bool
|
|
created_at: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class QuestDefinitionWindowRecord:
|
|
"""One row of ``quest_definition_windows``."""
|
|
|
|
definition_id: int
|
|
window: str
|
|
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"
|
|
)
|
|
_DEFINITION_COLUMNS = (
|
|
"id, title, description, icon, schedule_rule_id, due_time, "
|
|
"is_active, created_at"
|
|
)
|
|
_WINDOW_COLUMNS = "definition_id, window, due_time"
|
|
|
|
|
|
def _rule_from_row(row: tuple) -> ScheduleRuleRecord:
|
|
return ScheduleRuleRecord(
|
|
id=row[0],
|
|
rule_type=row[1],
|
|
interval=row[2],
|
|
weekday_set=row[3],
|
|
day_of_month=row[4],
|
|
nth_weekday=row[5],
|
|
month=row[6],
|
|
start_date=row[7],
|
|
end_date=row[8],
|
|
)
|
|
|
|
|
|
def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
|
|
return QuestDefinitionRecord(
|
|
id=row[0],
|
|
title=row[1],
|
|
description=row[2],
|
|
icon=row[3],
|
|
schedule_rule_id=row[4],
|
|
due_time=row[5],
|
|
is_active=bool(row[6]),
|
|
created_at=row[7],
|
|
)
|
|
|
|
|
|
def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord:
|
|
return QuestDefinitionWindowRecord(
|
|
definition_id=row[0],
|
|
window=row[1],
|
|
due_time=row[2],
|
|
)
|
|
|
|
|
|
#: 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."""
|
|
|
|
def __init__(self, database: NestQuestDatabase) -> None:
|
|
self._database = database
|
|
|
|
async def create(
|
|
self,
|
|
rule_type: str,
|
|
start_date: str,
|
|
*,
|
|
interval: int = 1,
|
|
weekday_set: str | None = None,
|
|
day_of_month: int | None = None,
|
|
nth_weekday: int | None = None,
|
|
month: int | None = None,
|
|
end_date: str | None = None,
|
|
) -> ScheduleRuleRecord:
|
|
"""Insert one rule and return the record as stored.
|
|
|
|
Rule-type coherence (a weekly rule needs ``weekday_set``, a
|
|
yearly rule needs ``month``, etc.) is enforced by the schema
|
|
CHECKs and surfaces as :class:`sqlite3.IntegrityError`. Date
|
|
shape (strict YYYY-MM-DD) is validated here because the
|
|
schema's lexical end>=start CHECK cannot prove a real calendar
|
|
date.
|
|
"""
|
|
_validate_date(start_date, "start_date")
|
|
if end_date is not None:
|
|
_validate_date(end_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(rule_id)
|
|
assert rule is not None
|
|
return rule
|
|
|
|
async def get(self, rule_id: int) -> ScheduleRuleRecord | None:
|
|
"""Return the rule with ``rule_id``, or None."""
|
|
row = await self._database.fetch_one(
|
|
f"SELECT {_RULE_COLUMNS} FROM schedule_rules WHERE id = ?",
|
|
(rule_id,),
|
|
)
|
|
return _rule_from_row(row) if row is not None else None
|
|
|
|
async def update(
|
|
self,
|
|
rule_id: int,
|
|
*,
|
|
rule_type: str | None | object = _UNSET,
|
|
interval: int | None | object = _UNSET,
|
|
weekday_set: str | None | object = _UNSET,
|
|
day_of_month: int | None | object = _UNSET,
|
|
nth_weekday: int | None | object = _UNSET,
|
|
month: int | None | object = _UNSET,
|
|
start_date: str | None | object = _UNSET,
|
|
end_date: str | None | object = _UNSET,
|
|
) -> int:
|
|
"""Update the given fields; returns rows updated (0 if absent).
|
|
|
|
Arguments default to the module sentinel ``_UNSET`` meaning
|
|
"leave this column alone"; passing ``None`` explicitly writes
|
|
SQL NULL, so callers can reopen an ended rule (``end_date``
|
|
back to NULL) or clear nullable shape fields. Passing
|
|
``None``/a new value for a date field validates its shape.
|
|
Editing a rule that a definition references changes future
|
|
materializations only — this method touches no instances
|
|
(Feature 06/07 separation).
|
|
"""
|
|
date_args = (
|
|
("start_date", start_date),
|
|
("end_date", end_date),
|
|
)
|
|
for field, value in date_args:
|
|
if value is not _UNSET and value is not None:
|
|
_validate_date(value, field)
|
|
assignments: list[str] = []
|
|
parameters: list[object] = []
|
|
for column, value in (
|
|
("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),
|
|
):
|
|
if value is not _UNSET:
|
|
assignments.append(f"{column} = ?")
|
|
parameters.append(value)
|
|
if not assignments:
|
|
return 0
|
|
parameters.append(rule_id)
|
|
result = await self._database.execute(
|
|
f"UPDATE schedule_rules SET {', '.join(assignments)} "
|
|
"WHERE id = ?",
|
|
tuple(parameters),
|
|
)
|
|
return result.rowcount
|
|
|
|
async def delete_if_unreferenced(self, rule_id: int) -> bool:
|
|
"""Delete the rule only if no definition references it.
|
|
|
|
Returns True if the rule was deleted, False if it is still
|
|
referenced (or does not exist — distinguish via :meth:`get`).
|
|
The reference check and the DELETE run inside one transaction
|
|
under the connection lock, so a definition cannot start
|
|
referencing the rule between the check and the delete.
|
|
"""
|
|
async with _connection_lock(self._database):
|
|
async with self._database.transaction():
|
|
row = await self._database.fetch_one(
|
|
"SELECT 1 FROM quest_definitions "
|
|
"WHERE schedule_rule_id = ? LIMIT 1",
|
|
(rule_id,),
|
|
)
|
|
if row is not None:
|
|
return False
|
|
result = await self._database.execute(
|
|
"DELETE FROM schedule_rules WHERE id = ?",
|
|
(rule_id,),
|
|
)
|
|
return result.rowcount > 0
|
|
|
|
|
|
class QuestDefinitionsDao:
|
|
"""Typed async access to ``quest_definitions`` and its assignees.
|
|
|
|
No delete method exists: definitions are deactivated via
|
|
:meth:`set_active`, never hard-deleted, so completion history keeps
|
|
its references (feature guardrail). Assignment is multi-assignee
|
|
(D-008): the definition row carries no child; assignees live in
|
|
``quest_definition_assignees`` and are managed explicitly so the
|
|
operations stay separately loggable and permission-gateable.
|
|
"""
|
|
|
|
def __init__(self, database: NestQuestDatabase) -> None:
|
|
self._database = database
|
|
|
|
async def create(
|
|
self,
|
|
title: str,
|
|
schedule_rule_id: int,
|
|
created_at: str,
|
|
*,
|
|
description: str | None = None,
|
|
icon: str | None = None,
|
|
due_time: str | None = None,
|
|
is_active: bool = True,
|
|
assignee_child_ids: list[int] | None = None,
|
|
) -> QuestDefinitionRecord:
|
|
"""Insert one definition and return the record as stored.
|
|
|
|
Validates the schedule rule as part of the same serialized
|
|
transaction as the insert: the rule must exist AT INSERT TIME —
|
|
a rule deleted concurrently cannot slip between the check and
|
|
the INSERT. ``assignee_child_ids`` (each child must exist and
|
|
be active) is applied in the same transaction, so a definition
|
|
can never be observed with a stale or missing assignee set; an
|
|
empty list or None creates an unassigned definition, which
|
|
callers may fill via :meth:`add_assignee`.
|
|
"""
|
|
async with _connection_lock(self._database):
|
|
async with self._database.transaction():
|
|
await self._validate_rule_exists(schedule_rule_id)
|
|
for child_id in assignee_child_ids or []:
|
|
await self._validate_assignable_child(child_id)
|
|
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,
|
|
schedule_rule_id,
|
|
due_time,
|
|
int(is_active),
|
|
created_at,
|
|
),
|
|
)
|
|
definition_id = result.lastrowid
|
|
for child_id in assignee_child_ids or []:
|
|
await self._database.execute(
|
|
"INSERT INTO quest_definition_assignees "
|
|
"(definition_id, child_id) VALUES (?, ?)",
|
|
(definition_id, child_id),
|
|
)
|
|
definition = await self.get(definition_id)
|
|
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.
|
|
|
|
MUST be called inside the connection lock so the verdict cannot
|
|
go stale before the caller's INSERT commits.
|
|
"""
|
|
rule = await self._database.fetch_one(
|
|
"SELECT 1 FROM schedule_rules WHERE id = ?",
|
|
(schedule_rule_id,),
|
|
)
|
|
if rule is None:
|
|
raise ValueError(
|
|
f"schedule rule {schedule_rule_id} does not exist"
|
|
)
|
|
|
|
async def get(self, definition_id: int) -> QuestDefinitionRecord | None:
|
|
"""Return the definition with ``definition_id``, or None."""
|
|
row = await self._database.fetch_one(
|
|
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
|
|
"WHERE id = ?",
|
|
(definition_id,),
|
|
)
|
|
return _definition_from_row(row) if row is not None else None
|
|
|
|
async def list_by_child(self, child_id: int) -> list[QuestDefinitionRecord]:
|
|
"""Return all definitions assigned to ``child_id``, newest first.
|
|
|
|
Assignment is read through ``quest_definition_assignees``
|
|
(D-008): a definition appears once per child regardless of how
|
|
many assignees share it.
|
|
"""
|
|
rows = await self._database.fetch_all(
|
|
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
|
|
"WHERE id IN (SELECT definition_id "
|
|
"FROM quest_definition_assignees WHERE child_id = ?) "
|
|
"ORDER BY id DESC",
|
|
(child_id,),
|
|
)
|
|
return [_definition_from_row(row) for row in rows]
|
|
|
|
async def list_active(self) -> list[QuestDefinitionRecord]:
|
|
"""Return all active definitions, oldest first (stable order)."""
|
|
rows = await self._database.fetch_all(
|
|
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
|
|
"WHERE is_active = 1 ORDER BY id"
|
|
)
|
|
return [_definition_from_row(row) for row in rows]
|
|
|
|
async def update(
|
|
self,
|
|
definition_id: int,
|
|
*,
|
|
title: str | None | object = _UNSET,
|
|
description: str | None | object = _UNSET,
|
|
icon: str | None | object = _UNSET,
|
|
due_time: str | None | object = _UNSET,
|
|
) -> int:
|
|
"""Update the given fields; returns rows updated (0 if absent).
|
|
|
|
Arguments default to the module sentinel ``_UNSET`` meaning
|
|
"leave this column alone"; passing ``None`` explicitly writes
|
|
SQL NULL, so callers can remove optional metadata
|
|
(description, icon, due_time). Assignment and activation are
|
|
deliberately NOT settable here: they have their own explicit
|
|
operations (:meth:`add_assignee`, :meth:`remove_assignee`,
|
|
:meth:`set_active`) so they stay separately loggable and
|
|
permission-gateable.
|
|
"""
|
|
assignments: list[str] = []
|
|
parameters: list[object] = []
|
|
for column, value in (
|
|
("title", title),
|
|
("description", description),
|
|
("icon", icon),
|
|
("due_time", due_time),
|
|
):
|
|
if value is not _UNSET:
|
|
assignments.append(f"{column} = ?")
|
|
parameters.append(value)
|
|
if not assignments:
|
|
return 0
|
|
parameters.append(definition_id)
|
|
result = await self._database.execute(
|
|
f"UPDATE quest_definitions SET {', '.join(assignments)} "
|
|
"WHERE id = ?",
|
|
tuple(parameters),
|
|
)
|
|
return result.rowcount
|
|
|
|
async def set_active(self, definition_id: int, is_active: bool) -> int:
|
|
"""Deactivate/reactivate; returns rows updated (0 if absent).
|
|
|
|
Deactivating stops future instance generation but leaves
|
|
existing instances and completion history untouched.
|
|
"""
|
|
result = await self._database.execute(
|
|
"UPDATE quest_definitions SET is_active = ? WHERE id = ?",
|
|
(int(is_active), definition_id),
|
|
)
|
|
return result.rowcount
|
|
|
|
async def add_assignee(
|
|
self, definition_id: int, child_id: int
|
|
) -> 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. 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.
|
|
"""
|
|
async with _connection_lock(self._database):
|
|
async with self._database.transaction():
|
|
await self._validate_assignable_child(child_id)
|
|
definition = await self._database.fetch_one(
|
|
"SELECT 1 FROM quest_definitions WHERE id = ?",
|
|
(definition_id,),
|
|
)
|
|
if definition is None:
|
|
raise ValueError(
|
|
f"quest definition {definition_id} does not exist"
|
|
)
|
|
await self._database.execute(
|
|
"INSERT INTO quest_definition_assignees "
|
|
"(definition_id, child_id) VALUES (?, ?) "
|
|
"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
|
|
) -> bool:
|
|
"""Remove the assignee link; True when a row was removed.
|
|
|
|
Future materialization stops for this (definition, child) pair;
|
|
existing instances and completion history are never touched
|
|
here (Feature 06 guardrail).
|
|
"""
|
|
async with _connection_lock(self._database):
|
|
result = await self._database.execute(
|
|
"DELETE FROM quest_definition_assignees "
|
|
"WHERE definition_id = ? AND child_id = ?",
|
|
(definition_id, child_id),
|
|
)
|
|
return result.rowcount > 0
|
|
|
|
async def list_assignees(self, definition_id: int) -> list[ChildRecord]:
|
|
"""Return the definition's assignees as child records.
|
|
|
|
Ordered by the children table's stable display order
|
|
(``sort_order``, then id) so callers render a deterministic
|
|
roster. A definition with no assignees returns [].
|
|
"""
|
|
rows = await self._database.fetch_all(
|
|
f"SELECT {_CHILD_COLUMNS} FROM children "
|
|
"WHERE id IN (SELECT child_id "
|
|
"FROM quest_definition_assignees WHERE definition_id = ?) "
|
|
"ORDER BY sort_order, id",
|
|
(definition_id,),
|
|
)
|
|
return [_child_from_row(row) for row in rows]
|
|
|
|
async def upsert_window(
|
|
self,
|
|
definition_id: int,
|
|
window: str,
|
|
*,
|
|
due_time: str | None = None,
|
|
) -> QuestDefinitionWindowRecord:
|
|
"""Declare (or re-declare) ``window`` on the definition.
|
|
|
|
The composite primary key makes the write idempotent per
|
|
(definition, window): re-declaring updates ``due_time`` in
|
|
place instead of duplicating. The window name must be one of
|
|
``const.QUEST_WINDOWS`` (the schema CHECK backs this up) and a
|
|
provided ``due_time`` must be strict 24-hour HH:MM. The
|
|
definition must exist — validated under the connection lock so
|
|
the verdict cannot go stale before the write.
|
|
"""
|
|
_validate_window(window)
|
|
if due_time is not None:
|
|
_validate_time(due_time, "due_time")
|
|
async with _connection_lock(self._database):
|
|
async with self._database.transaction():
|
|
definition = await self._database.fetch_one(
|
|
"SELECT 1 FROM quest_definitions WHERE id = ?",
|
|
(definition_id,),
|
|
)
|
|
if definition is None:
|
|
raise ValueError(
|
|
f"quest definition {definition_id} does not exist"
|
|
)
|
|
await self._database.execute(
|
|
"INSERT INTO quest_definition_windows "
|
|
"(definition_id, window, due_time) VALUES (?, ?, ?) "
|
|
"ON CONFLICT (definition_id, window) DO UPDATE SET "
|
|
"due_time = excluded.due_time",
|
|
(definition_id, window, due_time),
|
|
)
|
|
row = await self._database.fetch_one(
|
|
f"SELECT {_WINDOW_COLUMNS} "
|
|
"FROM quest_definition_windows "
|
|
"WHERE definition_id = ? AND window = ?",
|
|
(definition_id, window),
|
|
)
|
|
assert row is not None
|
|
return _window_from_row(row)
|
|
|
|
async def list_windows(
|
|
self, definition_id: int
|
|
) -> list[QuestDefinitionWindowRecord]:
|
|
"""Return the definition's windows in ``const`` order.
|
|
|
|
Ordered by the canonical window sequence (morning, afternoon,
|
|
evening) rather than insertion order, so callers always render
|
|
the Quest Log's three columns consistently. A definition with
|
|
no windows returns [].
|
|
"""
|
|
rows = await self._database.fetch_all(
|
|
f"SELECT {_WINDOW_COLUMNS} FROM quest_definition_windows "
|
|
"WHERE definition_id = ?",
|
|
(definition_id,),
|
|
)
|
|
by_name = {row[1]: _window_from_row(row) for row in rows}
|
|
return [
|
|
by_name[name]
|
|
for name in QUEST_WINDOWS
|
|
if name in by_name
|
|
]
|
|
|
|
async def remove_window(
|
|
self, definition_id: int, window: str
|
|
) -> bool:
|
|
"""Remove the window declaration; True when a row was removed.
|
|
|
|
Future materialization stops producing instances for this
|
|
(definition, window); existing instances and completion history
|
|
are never touched here (Feature 06 guardrail).
|
|
"""
|
|
_validate_window(window)
|
|
async with _connection_lock(self._database):
|
|
result = await self._database.execute(
|
|
"DELETE FROM quest_definition_windows "
|
|
"WHERE definition_id = ? AND window = ?",
|
|
(definition_id, window),
|
|
)
|
|
return result.rowcount > 0
|
|
|
|
async def _validate_assignable_child(self, child_id: int) -> None:
|
|
"""Raise ValueError unless the child exists and is active.
|
|
|
|
MUST be called inside the connection lock so the verdict cannot
|
|
go stale before the caller's INSERT commits.
|
|
"""
|
|
child = await self._database.fetch_one(
|
|
"SELECT is_active FROM children WHERE id = ?", (child_id,)
|
|
)
|
|
if child is None:
|
|
raise ValueError(f"child {child_id} does not exist")
|
|
if not child[0]:
|
|
raise ValueError(
|
|
f"child {child_id} is inactive and cannot be assigned"
|
|
) |