Edit a quest definition's metadata, rule, and windows (Feature 06, task 3)

This commit is contained in:
2026-09-17 08:54:03 -04:00
parent d9cdac87d7
commit a9e95d8303
3 changed files with 544 additions and 5 deletions
@@ -1,10 +1,11 @@
"""Business layer for creating quest definitions (Feature 06).
"""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 here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path; there is no
permission check here (that is Feature 09).
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path and
:func:`edit_quest_definition` the edit path; there is no permission
check here (that is Feature 09).
Validation policy mirrors :mod:`.children`:
@@ -43,9 +44,13 @@ from .dao_rules import (
QuestDefinitionRecord,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
ScheduleRuleStorage,
ScheduleRulesDao,
_UNSET,
_validate_time,
_validate_window,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
@@ -54,7 +59,7 @@ from .recurrence import ScheduleRule
@dataclass(frozen=True)
class CreatedQuestDefinition:
"""The persisted result of :func:`create_quest_definition`.
"""The persisted result of a create or edit.
``definition`` is the stored row; ``rule`` is the schedule rule
decoded back through the storage mapping; ``assignees`` and
@@ -169,6 +174,18 @@ def _normalize_windows(
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
async def create_quest_definition(
database: NestQuestDatabase,
title: str,
@@ -226,3 +243,89 @@ async def create_quest_definition(
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)
snapshot = await dao.edit_definition(
definition_id,
title=title_value,
description=description_value,
icon=icon_value,
rule=rule_storage,
windows=window_specs,
)
if rule_storage is not _UNSET:
decoded_rule = schedule_rule_from_storage(rule_storage)
else:
rule_record = await ScheduleRulesDao(database).get(
snapshot.definition.schedule_rule_id
)
assert rule_record is not None
decoded_rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(rule_record)
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)