147 files
This commit is contained in:
@@ -60,6 +60,16 @@ def _get_merged_tasks(entry: ConfigEntry) -> dict[str, Any]:
|
||||
_HISTORY_WINDOW = int(os.environ.get("MS_HISTORY_WINDOW", "20"))
|
||||
|
||||
|
||||
def _current_phase_summary(task_data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""{id, name, index, count} of the phase currently due, or None (#139)."""
|
||||
from ..helpers.phases import current_phase
|
||||
|
||||
phase = current_phase(task_data)
|
||||
if phase is None:
|
||||
return None
|
||||
return {"id": phase["id"], "name": phase["name"], "index": phase["index"], "count": phase["count"]}
|
||||
|
||||
|
||||
def _build_task_summary(
|
||||
hass: HomeAssistant,
|
||||
task_id: str,
|
||||
@@ -151,6 +161,13 @@ def _build_task_summary(
|
||||
# auto-created "buy" reminder, the owning part marker ({part_id}).
|
||||
"consumes_parts": task_data.get("consumes_parts"),
|
||||
"part_ref": task_data.get("part_ref"),
|
||||
# Task phases (#139): defs + cycle + Store-merged cursor, plus the
|
||||
# resolved current phase so every surface can label "what's due now"
|
||||
# without re-deriving the clamp.
|
||||
"phases": task_data.get("phases"),
|
||||
"phase_sequence": task_data.get("phase_sequence"),
|
||||
"phase_cursor": task_data.get("phase_cursor", 0),
|
||||
"current_phase": _current_phase_summary(task_data),
|
||||
"priority": task_data.get("priority") or DEFAULT_TASK_PRIORITY,
|
||||
# v2.10.0 archive: archived_at is the persisted timestamp (None = active);
|
||||
# `archived` is the convenience bool the frontend filters on; reason is
|
||||
@@ -623,6 +640,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
ws_postpone_task,
|
||||
ws_quick_complete_task,
|
||||
ws_reset_task,
|
||||
ws_set_task_phase,
|
||||
ws_skip_task,
|
||||
ws_snooze_task,
|
||||
ws_task_history,
|
||||
@@ -665,6 +683,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_checklist_progress)
|
||||
websocket_api.async_register_command(hass, ws_skip_task)
|
||||
websocket_api.async_register_command(hass, ws_reset_task)
|
||||
websocket_api.async_register_command(hass, ws_set_task_phase)
|
||||
websocket_api.async_register_command(hass, ws_snooze_task)
|
||||
websocket_api.async_register_command(hass, ws_postpone_task)
|
||||
websocket_api.async_register_command(hass, ws_create_part)
|
||||
|
||||
@@ -27,6 +27,7 @@ from ..const import (
|
||||
MAX_JSON_IMPORT_PAYLOAD_BYTES,
|
||||
)
|
||||
from ..helpers.global_options import get_default_warning_days
|
||||
from ..helpers.phases import clamp_phase_cursor, sanitize_phase_defs, sanitize_phase_sequence
|
||||
from ..helpers.qr_generator import (
|
||||
_ACTION_ICON_MAP,
|
||||
build_qr_url,
|
||||
@@ -728,6 +729,42 @@ async def ws_import_json(
|
||||
elif ref is not None:
|
||||
task_data.pop("part_ref", None)
|
||||
|
||||
# Task phases (#139): sanitize like the live WS write, remap each
|
||||
# phase's part links to the regenerated ids (same rules as the
|
||||
# task-level links above), and clamp the cursor to the imported
|
||||
# sequence. The cursor rides entry.data until the fresh entry's
|
||||
# first setup migrates it into the Store (dynamic field), so a
|
||||
# restore resumes mid-cycle.
|
||||
raw_defs = task_entry.get("phases")
|
||||
raw_seq = task_entry.get("phase_sequence")
|
||||
if isinstance(raw_defs, dict) and isinstance(raw_seq, list):
|
||||
defs = sanitize_phase_defs(raw_defs)
|
||||
for pdef in defs.values():
|
||||
plinks = pdef.get("consumes_parts")
|
||||
if not isinstance(plinks, list):
|
||||
continue
|
||||
kept = []
|
||||
for link in plinks:
|
||||
if not isinstance(link, dict):
|
||||
continue
|
||||
foreign = str(link.get("entry_id") or "").strip()
|
||||
if foreign:
|
||||
if hass.config_entries.async_get_entry(foreign) is not None:
|
||||
kept.append(dict(link))
|
||||
elif link.get("part_id") in part_id_map:
|
||||
kept.append(
|
||||
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
|
||||
)
|
||||
if kept:
|
||||
pdef["consumes_parts"] = kept
|
||||
else:
|
||||
pdef.pop("consumes_parts", None)
|
||||
seq = sanitize_phase_sequence(raw_seq, defs)
|
||||
if defs and seq:
|
||||
task_data["phases"] = defs
|
||||
task_data["phase_sequence"] = seq
|
||||
task_data["phase_cursor"] = clamp_phase_cursor(task_entry.get("phase_cursor"), len(seq))
|
||||
|
||||
# Sanitize critical fields from import data
|
||||
iv = task_data.get("interval_days")
|
||||
if iv is not None and (not isinstance(iv, int) or iv < 1):
|
||||
|
||||
@@ -13,6 +13,7 @@ from .tasks_actions import (
|
||||
ws_postpone_task,
|
||||
ws_quick_complete_task,
|
||||
ws_reset_task,
|
||||
ws_set_task_phase,
|
||||
ws_skip_task,
|
||||
ws_snooze_task,
|
||||
)
|
||||
@@ -69,6 +70,7 @@ __all__ = [
|
||||
"ws_postpone_task",
|
||||
"ws_quick_complete_task",
|
||||
"ws_reset_task",
|
||||
"ws_set_task_phase",
|
||||
"ws_skip_task",
|
||||
"ws_snooze_task",
|
||||
"ws_task_history",
|
||||
|
||||
@@ -348,6 +348,47 @@ async def ws_reset_task(
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/set_phase",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
# Index into phase_sequence — which cycle step is due NEXT.
|
||||
vol.Required("cursor"): vol.All(int, vol.Range(min=0, max=100)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_set_task_phase(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set which phase of a cyclic task is due next (#139).
|
||||
|
||||
The explicit correction path ("I'm actually at step 3") — completions
|
||||
rotate the cursor themselves and there is deliberately no phase picker
|
||||
in the complete dialog.
|
||||
"""
|
||||
ctx = _load_task_context(hass, connection, msg)
|
||||
if ctx is None:
|
||||
return
|
||||
rd, entry = ctx
|
||||
task = (entry.data.get(CONF_TASKS) or {}).get(msg["task_id"]) or {}
|
||||
sequence = task.get("phase_sequence") or []
|
||||
if not (task.get("phases") and sequence):
|
||||
connection.send_error(msg["id"], "no_phases", "Task has no phase cycle")
|
||||
return
|
||||
if msg["cursor"] >= len(sequence):
|
||||
connection.send_error(
|
||||
msg["id"], "invalid_cursor", f"cursor must be < {len(sequence)}"
|
||||
)
|
||||
return
|
||||
rd.store.set_phase_cursor(msg["task_id"], msg["cursor"])
|
||||
rd.store.async_delay_save()
|
||||
await rd.coordinator.async_refresh_now()
|
||||
connection.send_result(msg["id"], {"success": True})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/postpone",
|
||||
|
||||
@@ -105,6 +105,8 @@ TASK_UPDATE_FIELD_MAP = {
|
||||
"nfc_tag_id": "nfc_tag_id",
|
||||
"reading_unit": "reading_unit",
|
||||
"consumes_parts": "consumes_parts",
|
||||
"phases": "phases",
|
||||
"phase_sequence": "phase_sequence",
|
||||
"priority": "priority",
|
||||
"checklist": "checklist",
|
||||
"labels": "labels",
|
||||
@@ -115,6 +117,45 @@ TASK_UPDATE_FIELD_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _apply_phase_fields(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
task_data: dict[str, Any],
|
||||
raw_defs: object,
|
||||
raw_sequence: object,
|
||||
) -> None:
|
||||
"""Sanitize + store the phase config (#139) — shared by create and update.
|
||||
|
||||
Empty/invalid defs or sequence remove BOTH fields (a task either has a
|
||||
usable cycle or none). Per-phase consumes_parts get the same part-registry
|
||||
validation the task-level field gets.
|
||||
"""
|
||||
from ..const import CONF_PARTS
|
||||
from ..helpers.parts import sanitize_consumes_parts
|
||||
from ..helpers.phases import sanitize_phase_defs, sanitize_phase_sequence
|
||||
from . import foreign_part_resolver
|
||||
|
||||
defs = sanitize_phase_defs(raw_defs)
|
||||
for definition in defs.values():
|
||||
if "consumes_parts" in definition:
|
||||
links = sanitize_consumes_parts(
|
||||
definition["consumes_parts"],
|
||||
set(entry.data.get(CONF_PARTS) or {}),
|
||||
foreign_part_ids=foreign_part_resolver(hass),
|
||||
)
|
||||
if links:
|
||||
definition["consumes_parts"] = links
|
||||
else:
|
||||
del definition["consumes_parts"]
|
||||
sequence = sanitize_phase_sequence(raw_sequence, defs)
|
||||
if defs and sequence:
|
||||
task_data["phases"] = defs
|
||||
task_data["phase_sequence"] = sequence
|
||||
else:
|
||||
task_data.pop("phases", None)
|
||||
task_data.pop("phase_sequence", None)
|
||||
|
||||
|
||||
# Hoisted as a module constant so the schema/field-map parity tripwire
|
||||
# (tests/test_task_schema_parity.py, drift audit 2026-08) can introspect it.
|
||||
_TASK_CREATE_SCHEMA: dict[Any, Any] = {
|
||||
@@ -155,6 +196,10 @@ _TASK_CREATE_SCHEMA: dict[Any, Any] = {
|
||||
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
|
||||
# Spare parts consumed on completion: [{part_id, quantity}].
|
||||
vol.Optional("consumes_parts"): vol.Any(list, None),
|
||||
# Task phases (#139): cyclic content rotation on one cadence.
|
||||
# Shape-validated in helpers/phases.py at both write paths.
|
||||
vol.Optional("phases"): vol.Any(dict, None),
|
||||
vol.Optional("phase_sequence"): vol.Any(list, None),
|
||||
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
|
||||
vol.Optional("checklist"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH))], vol.Length(max=MAX_CHECKLIST_ITEMS)), None
|
||||
@@ -337,6 +382,8 @@ async def ws_create_task(
|
||||
)
|
||||
if links:
|
||||
task_data["consumes_parts"] = links
|
||||
if msg.get("phases") is not None or msg.get("phase_sequence") is not None:
|
||||
_apply_phase_fields(hass, entry, task_data, msg.get("phases"), msg.get("phase_sequence"))
|
||||
if msg.get("checklist"):
|
||||
task_data["checklist"] = msg["checklist"]
|
||||
if msg.get("labels"):
|
||||
@@ -422,6 +469,10 @@ _TASK_UPDATE_SCHEMA: dict[Any, Any] = {
|
||||
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
|
||||
# Spare parts consumed on completion: [{part_id, quantity}].
|
||||
vol.Optional("consumes_parts"): vol.Any(list, None),
|
||||
# Task phases (#139): cyclic content rotation on one cadence.
|
||||
# Shape-validated in helpers/phases.py at both write paths.
|
||||
vol.Optional("phases"): vol.Any(dict, None),
|
||||
vol.Optional("phase_sequence"): vol.Any(list, None),
|
||||
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
|
||||
vol.Optional("checklist"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH))], vol.Length(max=MAX_CHECKLIST_ITEMS)), None
|
||||
@@ -540,6 +591,24 @@ async def ws_update_task(
|
||||
foreign_part_ids=foreign_part_resolver(hass),
|
||||
)
|
||||
|
||||
# Phases (#139): the raw field-map copy above skipped validation (the
|
||||
# raw-field-map lesson) — sanitize here, and clamp the Store cursor
|
||||
# against the possibly shortened sequence (the Store wins on restore, so
|
||||
# an out-of-range cursor would otherwise survive the edit forever).
|
||||
if "phases" in msg or "phase_sequence" in msg:
|
||||
_apply_phase_fields(hass, entry, task, msg.get("phases", task.get("phases")), msg.get("phase_sequence", task.get("phase_sequence")))
|
||||
from ..helpers.phases import clamp_phase_cursor
|
||||
|
||||
rd_phase = _get_runtime_data(hass, msg["entry_id"])
|
||||
if rd_phase and rd_phase.store:
|
||||
seq = task.get("phase_sequence") or []
|
||||
if seq:
|
||||
cur = rd_phase.store.get_task_state(task_id).get("phase_cursor", 0)
|
||||
rd_phase.store.set_phase_cursor(task_id, clamp_phase_cursor(cur, len(seq)))
|
||||
else:
|
||||
rd_phase.store.get_task_state(task_id).pop("phase_cursor", None)
|
||||
rd_phase.store.async_delay_save()
|
||||
|
||||
# Recurrence resolution: an explicit nested `schedule` wins (calendar kinds
|
||||
# and kind-switches). Otherwise rebuild from the flat view ONLY when a real
|
||||
# legacy recurrence signal is present — a flat interval/due field, or a legacy
|
||||
|
||||
Reference in New Issue
Block a user