393 files
This commit is contained in:
@@ -12,14 +12,12 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import (
|
||||
BATTERY_FLEET_TASK_FLAG,
|
||||
CONF_OBJECT,
|
||||
CONF_TASKS,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DOMAIN,
|
||||
FLAT_SCHEDULE_TYPES,
|
||||
MAX_ASSIGNEE_POOL,
|
||||
@@ -28,7 +26,6 @@ from ..const import (
|
||||
MAX_DATE_LENGTH,
|
||||
MAX_ENTITY_SLUG_LENGTH,
|
||||
MAX_ICON_LENGTH,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_LABEL_LENGTH,
|
||||
MAX_LABELS,
|
||||
MAX_META_LENGTH,
|
||||
@@ -47,6 +44,7 @@ from ..const import (
|
||||
from ..helpers.aggregate import get_store
|
||||
from ..helpers.dates import INTERVAL_UNITS
|
||||
from ..helpers.entry_tasks import write_task
|
||||
from ..helpers.global_options import get_default_warning_days
|
||||
from ..helpers.notify_icons import is_valid_icon
|
||||
from ..helpers.permissions import require_write
|
||||
from ..helpers.sanitize import strip_task_runtime_state
|
||||
@@ -68,6 +66,7 @@ from . import (
|
||||
ID_FIELD,
|
||||
_load_object_entry,
|
||||
_load_object_task,
|
||||
_merge_global_options,
|
||||
_parse_iso_date,
|
||||
cleanup_group_refs,
|
||||
)
|
||||
@@ -135,6 +134,50 @@ def _normalize_nfc_tag(
|
||||
warnings.append(nfc_warn)
|
||||
return nfc_val
|
||||
|
||||
|
||||
def _refuse_archived_object(connection: websocket_api.ActiveConnection, msg: dict[str, Any], entry: ConfigEntry) -> bool:
|
||||
"""Send ``archived`` and return True for an archived object.
|
||||
|
||||
``task/move`` refused an archived target, but ``task/create`` and
|
||||
``task/duplicate`` added tasks to one — tasks nobody sees (the object is
|
||||
hidden) that still notified (bug audit 2026-09-26).
|
||||
"""
|
||||
if not (entry.data.get(CONF_OBJECT) or {}).get("archived_at"):
|
||||
return False
|
||||
connection.send_error(msg["id"], "archived", "An archived object cannot get new tasks")
|
||||
return True
|
||||
|
||||
|
||||
def _valid_due_date(connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> bool:
|
||||
"""False (after ``invalid_format``) when ``msg["due_date"]`` is set but not
|
||||
an ISO date; canonicalises it in place. The schema only capped the length,
|
||||
so any string became a one-time task's due date — which then never came
|
||||
due (bug audit 2026-09-26)."""
|
||||
if msg.get("due_date") is None:
|
||||
return True
|
||||
parsed = _parse_iso_date(connection, msg["id"], msg["due_date"], field="due_date", code="invalid_format")
|
||||
if parsed is None:
|
||||
return False
|
||||
msg["due_date"] = parsed.isoformat()
|
||||
return True
|
||||
|
||||
|
||||
async def _responsible_user_missing(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], *, current: Any = None
|
||||
) -> bool:
|
||||
"""Send ``invalid_user`` and return True when ``responsible_user_id`` names
|
||||
no user. ``user/assign`` checked it, create/update stored any id — the
|
||||
task then showed an unknown assignee and per-user notifications went
|
||||
nowhere (bug audit 2026-09-26). Empty / None mean "unassigned"; the
|
||||
``current`` (stored) value passes unchecked — an older client re-sends it
|
||||
on every save and must not be refused for someone else's deletion."""
|
||||
user_id = msg.get("responsible_user_id")
|
||||
if not user_id or user_id == current or await hass.auth.async_get_user(user_id) is not None:
|
||||
return False
|
||||
connection.send_error(msg["id"], "invalid_user", "User not found")
|
||||
return True
|
||||
|
||||
|
||||
# ws_update_task: wire key -> storage key. Almost all are identity; the one
|
||||
# rename is deliberate and load-bearing: the WS message envelope reserves
|
||||
# "type" for command routing ({"type": "maintenance_supporter/task/update"}),
|
||||
@@ -227,91 +270,114 @@ def _apply_phase_fields(
|
||||
task_data.pop("phase_sequence", None)
|
||||
|
||||
|
||||
# DRY audit 2026-09-26 B: the ONE validator per task field. task/create and
|
||||
# task/update carried ~37 verbatim copies of these; both schemas are built
|
||||
# from this map now — update wraps every field in a default-less
|
||||
# vol.Optional (omitted = unchanged), create requires _TASK_CREATE_REQUIRED
|
||||
# and fills _TASK_CREATE_DEFAULTS. Wire-keyed (``task_type`` = the task's
|
||||
# type; TASK_UPDATE_FIELD_MAP translates to storage keys).
|
||||
_TASK_FIELDS: dict[str, Any] = {
|
||||
"name": vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
||||
"task_type": vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
|
||||
"enabled": bool,
|
||||
"schedule_type": vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
|
||||
"interval_days": vol.Any(vol.All(int, vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])), None),
|
||||
"interval_unit": vol.In(INTERVAL_UNITS),
|
||||
"due_date": vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
"interval_anchor": vol.In(INTERVAL_ANCHORS),
|
||||
# Nested recurrence (calendar kinds: weekdays / nth_weekday / day_of_month).
|
||||
# Validated/canonicalized in the handler via Schedule.from_dict.
|
||||
"schedule": vol.Any(dict, None),
|
||||
# No create default: an omitted value takes the integration-wide
|
||||
# setting (get_default_warning_days), not the constant 7 (BR-A4).
|
||||
"warning_days": vol.All(int, vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])),
|
||||
"earliest_completion_days": vol.Any(
|
||||
vol.All(int, vol.Range(min=EARLIEST_COMPLETION_RANGE[0], max=EARLIEST_COMPLETION_RANGE[1])), None
|
||||
),
|
||||
"last_performed": vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
"trigger_config": vol.Any(dict, None),
|
||||
"notes": vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
||||
"documentation_url": vol.Any(vol.All(str, vol.Length(max=MAX_URL_LENGTH)), None),
|
||||
"responsible_user_id": vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
"assignee_pool": vol.Any(vol.All([vol.All(str, vol.Length(max=MAX_META_LENGTH))], vol.Length(max=MAX_ASSIGNEE_POOL)), None),
|
||||
"required_completion_fields": vol.Any([vol.In(REQUIRABLE_COMPLETION_FIELDS)], None),
|
||||
"rotation_strategy": vol.Any(vol.In(ROTATION_STRATEGY_VALUES), None),
|
||||
"entity_slug": vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
|
||||
"custom_icon": vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
|
||||
"nfc_tag_id": vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
|
||||
# Proof of presence (#139 family): completion only via NFC/QR scan.
|
||||
"require_tag_scan": vol.Any(bool, None),
|
||||
# #150: per-task skip lock — false hides Skip in the UIs and the
|
||||
# coordinator refuses (WS + voice), so automations cannot skip either.
|
||||
"allow_skip": vol.Any(bool, None),
|
||||
# #173: per-task notification mute — false = no reminders for this
|
||||
# task (status changes, repeats, lead-time, bundles); the dashboard
|
||||
# and entities still show it.
|
||||
"notify_enabled": vol.Any(bool, None),
|
||||
# #185: per-task push-notification icon ("mdi:…"); null/"" = the
|
||||
# maintenance type's default. Shape-checked by _validate_notify_icon.
|
||||
"notify_icon": vol.Any(vol.All(str, vol.Length(max=MAX_NOTIFY_ICON_LENGTH)), None),
|
||||
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
|
||||
"reading_unit": vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
|
||||
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
|
||||
# by helpers/reading_slots.sanitize_reading_slots at both write paths.
|
||||
"readings": vol.Any(list, None),
|
||||
# Spare parts consumed on completion: [{part_id, quantity}].
|
||||
"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.
|
||||
"phases": vol.Any(dict, None),
|
||||
"phase_sequence": vol.Any(list, None),
|
||||
"priority": vol.In(TASK_PRIORITIES),
|
||||
"checklist": vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH))], vol.Length(max=MAX_CHECKLIST_ITEMS)), None
|
||||
),
|
||||
"labels": vol.Any(vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), None),
|
||||
# D#183: todo.* entity ids the due task is mirrored into ([] / None = off).
|
||||
"mirror_todo_entities": vol.Any(
|
||||
vol.All([vol.All(str, vol.Match(MIRROR_TODO_ENTITY_PATTERN))], vol.Length(max=MAX_MIRROR_TODO_LISTS)), None
|
||||
),
|
||||
# HH:MM strict (00–23 : 00–59). None clears the time → midnight semantic.
|
||||
"schedule_time": vol.Any(vol.All(str, vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$")), None),
|
||||
# v1.3.0: per-task on_complete_action + quick_complete_defaults.
|
||||
# Both kept loose at the schema level (vol.Any(dict, None)); strict
|
||||
# field-by-field validation lives in helpers/sanitize.py so the
|
||||
# config-flow path (which doesn't go through this schema) gets
|
||||
# identical validation behaviour.
|
||||
"on_complete_action": vol.Any(dict, None),
|
||||
"quick_complete_defaults": vol.Any(dict, None),
|
||||
}
|
||||
|
||||
#: The only field task/create requires.
|
||||
_TASK_CREATE_REQUIRED: frozenset[str] = frozenset({"name"})
|
||||
#: task/create's values for omitted fields (task/update has none — an omitted
|
||||
#: field there means "leave unchanged").
|
||||
_TASK_CREATE_DEFAULTS: dict[str, Any] = {
|
||||
"task_type": "custom",
|
||||
"schedule_type": "time_based",
|
||||
"interval_unit": "days",
|
||||
"interval_anchor": "completion",
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
|
||||
def _create_marker(key: str) -> vol.Marker:
|
||||
"""The task/create schema marker for one ``_TASK_FIELDS`` key."""
|
||||
if key in _TASK_CREATE_REQUIRED:
|
||||
return vol.Required(key)
|
||||
if key in _TASK_CREATE_DEFAULTS:
|
||||
return vol.Optional(key, default=_TASK_CREATE_DEFAULTS[key])
|
||||
return vol.Optional(key)
|
||||
|
||||
|
||||
# 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] = {
|
||||
vol.Required("type"): "maintenance_supporter/task/create",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
||||
vol.Optional("task_type", default="custom"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
|
||||
vol.Optional("schedule_type", default="time_based"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
|
||||
vol.Optional("interval_days"): vol.Any(
|
||||
vol.All(int, vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])), None
|
||||
),
|
||||
vol.Optional("interval_unit", default="days"): vol.In(INTERVAL_UNITS),
|
||||
vol.Optional("due_date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
vol.Optional("interval_anchor", default="completion"): vol.In(INTERVAL_ANCHORS),
|
||||
# Nested recurrence (calendar kinds: weekdays / nth_weekday / day_of_month).
|
||||
# Validated/canonicalized in the handler via Schedule.from_dict.
|
||||
vol.Optional("schedule"): vol.Any(dict, None),
|
||||
vol.Optional("warning_days", default=DEFAULT_WARNING_DAYS): vol.All(
|
||||
int, vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])
|
||||
),
|
||||
vol.Optional("earliest_completion_days"): vol.Any(
|
||||
vol.All(int, vol.Range(min=EARLIEST_COMPLETION_RANGE[0], max=EARLIEST_COMPLETION_RANGE[1])), None
|
||||
),
|
||||
vol.Optional("last_performed"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
vol.Optional("trigger_config"): vol.Any(dict, None),
|
||||
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
||||
vol.Optional("documentation_url"): vol.Any(vol.All(str, vol.Length(max=MAX_URL_LENGTH)), None),
|
||||
vol.Optional("responsible_user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
vol.Optional("assignee_pool"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_META_LENGTH))], vol.Length(max=MAX_ASSIGNEE_POOL)), None
|
||||
),
|
||||
vol.Optional("required_completion_fields"): vol.Any([vol.In(REQUIRABLE_COMPLETION_FIELDS)], None),
|
||||
vol.Optional("rotation_strategy"): vol.Any(vol.In(ROTATION_STRATEGY_VALUES), None),
|
||||
vol.Optional("entity_slug"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
|
||||
vol.Optional("custom_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
|
||||
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
|
||||
# Proof of presence (#139 family): completion only via NFC/QR scan.
|
||||
vol.Optional("require_tag_scan"): vol.Any(bool, None),
|
||||
# #150: per-task skip lock — false hides Skip in the UIs and the
|
||||
# coordinator refuses (WS + voice), so automations cannot skip either.
|
||||
vol.Optional("allow_skip"): vol.Any(bool, None),
|
||||
# #173: per-task notification mute — false = no reminders for this
|
||||
# task (status changes, repeats, lead-time, bundles); the dashboard
|
||||
# and entities still show it.
|
||||
vol.Optional("notify_enabled"): vol.Any(bool, None),
|
||||
# #185: per-task push-notification icon ("mdi:…"); null/"" = the
|
||||
# maintenance type's default. Shape-checked by _validate_notify_icon.
|
||||
vol.Optional("notify_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_NOTIFY_ICON_LENGTH)), None),
|
||||
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
|
||||
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
|
||||
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
|
||||
# by helpers/reading_slots.sanitize_reading_slots at both write paths.
|
||||
vol.Optional("readings"): vol.Any(list, 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
|
||||
),
|
||||
vol.Optional("labels"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), None
|
||||
),
|
||||
# D#183: todo.* entity ids the due task is mirrored into ([] / None = off).
|
||||
vol.Optional("mirror_todo_entities"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Match(MIRROR_TODO_ENTITY_PATTERN))], vol.Length(max=MAX_MIRROR_TODO_LISTS)), None
|
||||
),
|
||||
# HH:MM strict (00–23 : 00–59). None clears the time → midnight semantic.
|
||||
vol.Optional("schedule_time"): vol.Any(
|
||||
vol.All(str, vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$")),
|
||||
None,
|
||||
),
|
||||
# v1.3.0: per-task on_complete_action + quick_complete_defaults.
|
||||
# Both kept loose at the schema level (vol.Any(dict, None)); strict
|
||||
# field-by-field validation lives in helpers/sanitize.py so the
|
||||
# config-flow path (which doesn't go through this schema) gets
|
||||
# identical validation behaviour.
|
||||
vol.Optional("on_complete_action"): vol.Any(dict, None),
|
||||
vol.Optional("quick_complete_defaults"): vol.Any(dict, None),
|
||||
vol.Optional("enabled", default=True): bool,
|
||||
vol.Optional("dry_run", default=False): bool,
|
||||
}
|
||||
_TASK_CREATE_SCHEMA: dict[Any, Any] = {
|
||||
vol.Required("type"): "maintenance_supporter/task/create",
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
**{_create_marker(key): validator for key, validator in _TASK_FIELDS.items()},
|
||||
vol.Optional("dry_run", default=False): bool,
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(_TASK_CREATE_SCHEMA)
|
||||
@@ -323,8 +389,12 @@ async def ws_create_task(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Add a new task to an existing maintenance object."""
|
||||
# The only await before the entry read — done first so nothing below
|
||||
# works on a snapshot taken before it.
|
||||
if await _responsible_user_missing(hass, connection, msg):
|
||||
return
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
if entry is None or _refuse_archived_object(connection, msg, entry):
|
||||
return
|
||||
|
||||
task_id = uuid4().hex
|
||||
@@ -332,6 +402,8 @@ async def ws_create_task(
|
||||
if not name:
|
||||
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
|
||||
return
|
||||
if not _valid_due_date(connection, msg):
|
||||
return
|
||||
|
||||
task_data: dict[str, Any] = {
|
||||
"id": task_id,
|
||||
@@ -339,7 +411,7 @@ async def ws_create_task(
|
||||
"name": name,
|
||||
"type": msg.get("task_type", "custom"),
|
||||
"enabled": msg.get("enabled", True),
|
||||
"warning_days": msg.get("warning_days", DEFAULT_WARNING_DAYS),
|
||||
"warning_days": msg["warning_days"] if msg.get("warning_days") is not None else get_default_warning_days(hass),
|
||||
# Anchor for next_due fallback when last_performed is None (issue #30).
|
||||
# Use HA's timezone-aware "today" to match next_due computation.
|
||||
"created_at": dt_util.now().date().isoformat(),
|
||||
@@ -378,7 +450,9 @@ async def ws_create_task(
|
||||
if msg.get("interval_anchor", "completion") != "completion":
|
||||
task_data["interval_anchor"] = msg["interval_anchor"]
|
||||
if msg.get("last_performed") is not None:
|
||||
lp_date = _parse_iso_date(connection, msg["id"], msg["last_performed"], field="last_performed", code="invalid_format")
|
||||
lp_date = _parse_iso_date(
|
||||
connection, msg["id"], msg["last_performed"], field="last_performed", code="invalid_format", not_future=True
|
||||
)
|
||||
if lp_date is None:
|
||||
return
|
||||
initial_last_performed = msg["last_performed"]
|
||||
@@ -507,9 +581,10 @@ async def ws_create_task(
|
||||
task_data["on_complete_action"] = msg["on_complete_action"]
|
||||
if msg.get("quick_complete_defaults"):
|
||||
task_data["quick_complete_defaults"] = msg["quick_complete_defaults"]
|
||||
from ..helpers.sanitize import cap_action_field, cap_quick_complete_defaults_field
|
||||
from ..helpers.sanitize import cap_action_field, cap_quick_complete_defaults_field, stamp_action_owner
|
||||
|
||||
cap_action_field(task_data)
|
||||
stamp_action_owner(task_data, connection.user.id if connection.user else None)
|
||||
cap_quick_complete_defaults_field(task_data)
|
||||
|
||||
# Dry-run mode: validate only, do not persist
|
||||
@@ -540,81 +615,13 @@ async def ws_create_task(
|
||||
|
||||
# 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_UPDATE_SCHEMA: dict[Any, Any] = {
|
||||
vol.Required("type"): "maintenance_supporter/task/update",
|
||||
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)),
|
||||
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
||||
vol.Optional("task_type"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
|
||||
vol.Optional("enabled"): bool,
|
||||
vol.Optional("schedule_type"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
|
||||
vol.Optional("interval_days"): vol.Any(
|
||||
vol.All(int, vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])), None
|
||||
),
|
||||
vol.Optional("interval_unit"): vol.In(INTERVAL_UNITS),
|
||||
vol.Optional("due_date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
vol.Optional("interval_anchor"): vol.In(INTERVAL_ANCHORS),
|
||||
# Nested recurrence (calendar kinds); see create schema.
|
||||
vol.Optional("schedule"): vol.Any(dict, None),
|
||||
vol.Optional("warning_days"): vol.All(int, vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])),
|
||||
vol.Optional("earliest_completion_days"): vol.Any(
|
||||
vol.All(int, vol.Range(min=EARLIEST_COMPLETION_RANGE[0], max=EARLIEST_COMPLETION_RANGE[1])), None
|
||||
),
|
||||
vol.Optional("last_performed"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
vol.Optional("trigger_config"): vol.Any(dict, None),
|
||||
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
||||
vol.Optional("documentation_url"): vol.Any(vol.All(str, vol.Length(max=MAX_URL_LENGTH)), None),
|
||||
vol.Optional("responsible_user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
|
||||
vol.Optional("assignee_pool"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_META_LENGTH))], vol.Length(max=MAX_ASSIGNEE_POOL)), None
|
||||
),
|
||||
vol.Optional("required_completion_fields"): vol.Any([vol.In(REQUIRABLE_COMPLETION_FIELDS)], None),
|
||||
vol.Optional("rotation_strategy"): vol.Any(vol.In(ROTATION_STRATEGY_VALUES), None),
|
||||
vol.Optional("entity_slug"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
|
||||
vol.Optional("custom_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
|
||||
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
|
||||
# Proof of presence (#139 family): completion only via NFC/QR scan.
|
||||
vol.Optional("require_tag_scan"): vol.Any(bool, None),
|
||||
# #150: per-task skip lock — false hides Skip in the UIs and the
|
||||
# coordinator refuses (WS + voice), so automations cannot skip either.
|
||||
vol.Optional("allow_skip"): vol.Any(bool, None),
|
||||
# #173: per-task notification mute — false = no reminders for this
|
||||
# task (status changes, repeats, lead-time, bundles); the dashboard
|
||||
# and entities still show it.
|
||||
vol.Optional("notify_enabled"): vol.Any(bool, None),
|
||||
# #185: per-task push-notification icon ("mdi:…"); null/"" = the
|
||||
# maintenance type's default. Shape-checked by _validate_notify_icon.
|
||||
vol.Optional("notify_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_NOTIFY_ICON_LENGTH)), None),
|
||||
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
|
||||
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
|
||||
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
|
||||
# by helpers/reading_slots.sanitize_reading_slots at both write paths.
|
||||
vol.Optional("readings"): vol.Any(list, 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
|
||||
),
|
||||
vol.Optional("labels"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), None
|
||||
),
|
||||
# D#183: see create schema.
|
||||
vol.Optional("mirror_todo_entities"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Match(MIRROR_TODO_ENTITY_PATTERN))], vol.Length(max=MAX_MIRROR_TODO_LISTS)), None
|
||||
),
|
||||
vol.Optional("schedule_time"): vol.Any(
|
||||
vol.All(str, vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$")),
|
||||
None,
|
||||
),
|
||||
# v1.3.0: same loose schema as create. Sanitize layer enforces shape.
|
||||
vol.Optional("on_complete_action"): vol.Any(dict, None),
|
||||
vol.Optional("quick_complete_defaults"): vol.Any(dict, None),
|
||||
}
|
||||
# Every _TASK_FIELDS key optional without a default (DRY audit 2026-09-26 B).
|
||||
_TASK_UPDATE_SCHEMA: dict[Any, Any] = {
|
||||
vol.Required("type"): "maintenance_supporter/task/update",
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
**{vol.Optional(key): validator for key, validator in _TASK_FIELDS.items()},
|
||||
}
|
||||
|
||||
|
||||
@websocket_api.websocket_command(_TASK_UPDATE_SCHEMA)
|
||||
@@ -626,6 +633,11 @@ async def ws_update_task(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update an existing task."""
|
||||
# First, before the task is read for the edit: the user lookup is an await.
|
||||
peek = hass.config_entries.async_get_entry(msg["entry_id"])
|
||||
current = ((peek.data.get(CONF_TASKS) or {}).get(msg["task_id"]) or {}).get("responsible_user_id") if peek else None
|
||||
if await _responsible_user_missing(hass, connection, msg, current=current):
|
||||
return
|
||||
ctx = _load_object_task(hass, connection, msg)
|
||||
if ctx is None:
|
||||
return
|
||||
@@ -674,10 +686,15 @@ async def ws_update_task(
|
||||
if "nfc_tag_id" in msg:
|
||||
_normalize_nfc_tag(hass, msg, tc_warnings, exclude_task_id=task_id)
|
||||
|
||||
if not _valid_due_date(connection, msg):
|
||||
return
|
||||
|
||||
# Validate last_performed date format if provided
|
||||
if (
|
||||
msg.get("last_performed") is not None
|
||||
and _parse_iso_date(connection, msg["id"], msg["last_performed"], field="last_performed", code="invalid_format")
|
||||
and _parse_iso_date(
|
||||
connection, msg["id"], msg["last_performed"], field="last_performed", code="invalid_format", not_future=True
|
||||
)
|
||||
is None
|
||||
):
|
||||
return
|
||||
@@ -738,6 +755,11 @@ async def ws_update_task(
|
||||
# D#183: an empty mirror list means "off" — drop the key, don't store [].
|
||||
if "mirror_todo_entities" in msg and not msg["mirror_todo_entities"]:
|
||||
task.pop("mirror_todo_entities", None)
|
||||
# No rotation is ABSENCE: the dialog sends null on every save, and the
|
||||
# stored None came back as the options flow's select default, failing
|
||||
# its validation — the task form could not be saved (bug audit 2026-09-26).
|
||||
if "rotation_strategy" in msg and not msg["rotation_strategy"]:
|
||||
task.pop("rotation_strategy", None)
|
||||
|
||||
# The loop above copies values verbatim, which is wrong for part links:
|
||||
# `task/create` validates them and `task/update` did not, so an edit could
|
||||
@@ -834,9 +856,14 @@ async def ws_update_task(
|
||||
sanitize_assignee_pool,
|
||||
sanitize_labels,
|
||||
seed_rotation_assignee,
|
||||
settle_action_owner,
|
||||
)
|
||||
|
||||
# A changed action is re-stamped with the saving user; an unchanged one
|
||||
# (the panel dialog sends it back on every save) keeps the owner it was
|
||||
# stored with — an operator editing the notes must not re-author it.
|
||||
cap_action_field(task)
|
||||
settle_action_owner(task, stored_task.get("on_complete_action"), connection.user.id if connection.user else None)
|
||||
cap_quick_complete_defaults_field(task)
|
||||
if "labels" in task:
|
||||
task["labels"] = sanitize_labels(task["labels"])
|
||||
@@ -882,8 +909,8 @@ async def ws_update_task(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/delete",
|
||||
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)),
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@@ -911,6 +938,8 @@ async def async_delete_task(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
task_id: str,
|
||||
*,
|
||||
removed_state: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Remove a task and all its side-state from an object entry.
|
||||
|
||||
@@ -919,13 +948,17 @@ async def async_delete_task(
|
||||
/ entity-registry / group-ref / repair-issue cleanup, but NOT the entry
|
||||
reload and NOT any WS reply — the caller reloads (once, even for a batch) and
|
||||
replies. Returns False when ``task_id`` isn't in the entry.
|
||||
|
||||
``removed_state`` (task/move): filled with the task's Store state as it
|
||||
was at the moment of removal — the mirror cleanup before it awaits, and a
|
||||
completion landing there was lost from a snapshot taken earlier (bug
|
||||
audit 2026-09-26).
|
||||
"""
|
||||
new_data = dict(entry.data)
|
||||
new_tasks = dict(new_data.get(CONF_TASKS, {}))
|
||||
if task_id not in new_tasks:
|
||||
return False
|
||||
|
||||
old_trigger_config = new_tasks[task_id].get("trigger_config")
|
||||
# Adopted problem-sensor task? Preserve its notes + one-time setup
|
||||
# (responsible user, priority, labels, part link) for a later re-adopt
|
||||
# (no-op for everything else).
|
||||
@@ -948,6 +981,8 @@ async def async_delete_task(
|
||||
mirror = hass.data.get(DOMAIN, {}).get("todo_mirror")
|
||||
if mirror is not None and hasattr(mirror, "async_forget_task"):
|
||||
await mirror.async_forget_task(store, task_id)
|
||||
if removed_state is not None:
|
||||
removed_state.update(deepcopy(store.get_task_state(task_id)))
|
||||
store.remove_task(task_id)
|
||||
await store.async_save()
|
||||
|
||||
@@ -980,30 +1015,20 @@ async def async_delete_task(
|
||||
# persistent and task-id keyed — without this, deleted ids accumulate
|
||||
# there forever and confuse the vacation preview UI.
|
||||
from ..const import CONF_VACATION_EXEMPT_TASK_IDS
|
||||
from ..helpers.global_options import get_global_entry
|
||||
from ..helpers.global_options import get_global_entry, get_global_options
|
||||
|
||||
ge = get_global_entry(hass)
|
||||
if ge is not None:
|
||||
exempt = ge.options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
|
||||
exempt = get_global_options(hass).get(CONF_VACATION_EXEMPT_TASK_IDS) or []
|
||||
if isinstance(exempt, list) and task_id in exempt:
|
||||
hass.config_entries.async_update_entry(
|
||||
ge,
|
||||
options={
|
||||
**dict(ge.options),
|
||||
CONF_VACATION_EXEMPT_TASK_IDS: [t for t in exempt if t != task_id],
|
||||
},
|
||||
)
|
||||
_merge_global_options(hass, ge, {CONF_VACATION_EXEMPT_TASK_IDS: [t for t in exempt if t != task_id]})
|
||||
|
||||
# Clean up any repair issues referencing this task
|
||||
if old_trigger_config:
|
||||
from ..entity.triggers import normalize_entity_ids
|
||||
# Every repair issue about this task — not only its missing-trigger
|
||||
# ones: a stale completion-action target outlived the task in Settings →
|
||||
# Repairs with nothing left to fix (bug audit 2026-09-26, BR-A8).
|
||||
from ..helpers.issues import async_purge_task_issues
|
||||
|
||||
for eid in normalize_entity_ids(old_trigger_config):
|
||||
ir.async_delete_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"missing_trigger_{entry.entry_id}_{task_id}_{eid}",
|
||||
)
|
||||
async_purge_task_issues(hass, entry.entry_id, task_id)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1011,8 +1036,8 @@ async def async_delete_task(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/duplicate",
|
||||
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)),
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@@ -1035,6 +1060,17 @@ async def ws_duplicate_task(
|
||||
if ctx is None:
|
||||
return
|
||||
entry, _rd, source = ctx
|
||||
if _refuse_archived_object(connection, msg, entry):
|
||||
return
|
||||
# Like task/move: a buy reminder belongs to its part's reconciler (a copy
|
||||
# carrying part_ref was a second reminder for the same low episode) and
|
||||
# the fleet task IS the battery fleet — a copy carrying the flag was a
|
||||
# second fleet task (bug audit 2026-09-27).
|
||||
from ..helpers.parts import PART_REF_FIELD
|
||||
|
||||
if source.get(PART_REF_FIELD) or source.get(BATTERY_FLEET_TASK_FLAG):
|
||||
connection.send_error(msg["id"], "invalid_input", "A spare-part buy task or the battery fleet task cannot be duplicated")
|
||||
return
|
||||
|
||||
new_task = deepcopy(dict(source))
|
||||
new_task["id"] = uuid4().hex
|
||||
|
||||
Reference in New Issue
Block a user