200 files
This commit is contained in:
@@ -160,6 +160,8 @@ def _build_task_summary(
|
||||
"require_tag_scan": bool(task_data.get("require_tag_scan", False)),
|
||||
# #150: skip lock — False disables skipping on every surface.
|
||||
"allow_skip": task_data.get("allow_skip") is not False,
|
||||
# #173: False = this task sends no reminders.
|
||||
"notify_enabled": task_data.get("notify_enabled") is not False,
|
||||
# v2.20 (#83): unit for `reading`-type tasks; values live in history.
|
||||
"reading_unit": task_data.get("reading_unit"),
|
||||
# #161 phase 2: reading slots [{id, name, unit}] ([] = single value).
|
||||
@@ -651,6 +653,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
ws_delete_task,
|
||||
ws_duplicate_task,
|
||||
ws_list_tasks,
|
||||
ws_move_task,
|
||||
ws_postpone_task,
|
||||
ws_quick_complete_task,
|
||||
ws_reset_task,
|
||||
@@ -688,6 +691,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_update_task)
|
||||
websocket_api.async_register_command(hass, ws_delete_task)
|
||||
websocket_api.async_register_command(hass, ws_duplicate_task)
|
||||
websocket_api.async_register_command(hass, ws_move_task)
|
||||
websocket_api.async_register_command(hass, ws_archive_task)
|
||||
websocket_api.async_register_command(hass, ws_unarchive_task)
|
||||
websocket_api.async_register_command(hass, ws_list_tasks)
|
||||
|
||||
@@ -796,6 +796,7 @@ async def ws_import_json(
|
||||
"nfc_tag_id",
|
||||
"require_tag_scan",
|
||||
"allow_skip",
|
||||
"notify_enabled",
|
||||
"responsible_user_id",
|
||||
"entity_slug",
|
||||
"trigger_config",
|
||||
|
||||
@@ -22,6 +22,7 @@ from .tasks_crud import (
|
||||
ws_create_task,
|
||||
ws_delete_task,
|
||||
ws_duplicate_task,
|
||||
ws_move_task,
|
||||
ws_update_task,
|
||||
)
|
||||
from .tasks_history import ws_update_history_entry
|
||||
@@ -67,6 +68,7 @@ __all__ = [
|
||||
"ws_delete_task",
|
||||
"ws_duplicate_task",
|
||||
"ws_list_tasks",
|
||||
"ws_move_task",
|
||||
"ws_postpone_task",
|
||||
"ws_quick_complete_task",
|
||||
"ws_reset_task",
|
||||
|
||||
@@ -105,6 +105,7 @@ TASK_UPDATE_FIELD_MAP = {
|
||||
"nfc_tag_id": "nfc_tag_id",
|
||||
"require_tag_scan": "require_tag_scan",
|
||||
"allow_skip": "allow_skip",
|
||||
"notify_enabled": "notify_enabled",
|
||||
"reading_unit": "reading_unit",
|
||||
"readings": "readings",
|
||||
"consumes_parts": "consumes_parts",
|
||||
@@ -200,6 +201,10 @@ _TASK_CREATE_SCHEMA: dict[Any, Any] = {
|
||||
# #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),
|
||||
# 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
|
||||
@@ -383,6 +388,9 @@ async def ws_create_task(
|
||||
# #150: stored only when False — absence means skipping is allowed.
|
||||
if msg.get("allow_skip") is False:
|
||||
task_data["allow_skip"] = False
|
||||
# #173: same shape — stored only when False (absence = notifications on).
|
||||
if msg.get("notify_enabled") is False:
|
||||
task_data["notify_enabled"] = False
|
||||
# v2.20 (#83): unit for `reading`-type tasks.
|
||||
if msg.get("reading_unit") is not None:
|
||||
task_data["reading_unit"] = (msg["reading_unit"] or "").strip() or None
|
||||
@@ -492,6 +500,10 @@ _TASK_UPDATE_SCHEMA: dict[Any, Any] = {
|
||||
# #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),
|
||||
# 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
|
||||
@@ -612,6 +624,12 @@ async def ws_update_task(
|
||||
task["allow_skip"] = False
|
||||
else:
|
||||
task.pop("allow_skip", None)
|
||||
# #173: notify_enabled follows the same only-when-False rule.
|
||||
if "notify_enabled" in msg:
|
||||
if msg["notify_enabled"] is False:
|
||||
task["notify_enabled"] = False
|
||||
else:
|
||||
task.pop("notify_enabled", 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
|
||||
@@ -925,3 +943,50 @@ async def ws_duplicate_task(
|
||||
await async_persist_task(hass, entry, new_task)
|
||||
|
||||
connection.send_result(msg["id"], {"task_id": new_task["id"]})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/task/move",
|
||||
vol.Required("entry_id"): str,
|
||||
vol.Required("task_id"): str,
|
||||
vol.Required("target_entry_id"): str,
|
||||
}
|
||||
)
|
||||
@require_write
|
||||
@websocket_api.async_response
|
||||
async def ws_move_task(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Move a task to another object — config, history, readings and trigger
|
||||
state travel with it; the task gets a new reference number and its
|
||||
entities are recreated under the target object (forum #23)."""
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
return
|
||||
if msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
|
||||
connection.send_error(msg["id"], "not_found", "Task not found")
|
||||
return
|
||||
target = _load_object_entry(
|
||||
hass, connection, {**msg, "entry_id": msg["target_entry_id"]}, not_found_message="Target object not found"
|
||||
)
|
||||
if target is None:
|
||||
return
|
||||
if target.entry_id == entry.entry_id:
|
||||
connection.send_error(msg["id"], "invalid_target", "The task already belongs to that object")
|
||||
return
|
||||
if (target.data.get(CONF_OBJECT) or {}).get("archived_at"):
|
||||
connection.send_error(msg["id"], "invalid_target", "The target object is archived")
|
||||
return
|
||||
from .tasks_persist import async_move_task
|
||||
|
||||
try:
|
||||
await async_move_task(hass, entry, target, msg["task_id"])
|
||||
except ValueError as err:
|
||||
connection.send_error(msg["id"], "limit_reached", str(err))
|
||||
return
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.config_entries.async_reload(target.entry_id)
|
||||
connection.send_result(msg["id"], {"task_id": msg["task_id"], "entry_id": target.entry_id})
|
||||
|
||||
@@ -223,3 +223,90 @@ async def async_update_task_simple(
|
||||
new_data[CONF_TASKS] = new_tasks
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
await hass.config_entries.async_reload(entry_id)
|
||||
|
||||
|
||||
async def async_move_task(
|
||||
hass: HomeAssistant,
|
||||
source: ConfigEntry,
|
||||
target: ConfigEntry,
|
||||
task_id: str,
|
||||
) -> None:
|
||||
"""Move a task — config AND dynamic state — from one object entry to another.
|
||||
|
||||
What travels: the task's config (schedule, trigger, checklist, parts links,
|
||||
slug, NFC tag …) and its Store state (history, last_performed, planned
|
||||
due, adaptive config, phase cursor, checklist progress, trigger runtime
|
||||
incl. counter baselines). Group memberships follow the task. What does
|
||||
not: the reference number (``ref_no`` and the history entries' numbers —
|
||||
the target object numbers it afresh on its next refresh) and document
|
||||
links (documents belong to the source object; ``async_delete_task``
|
||||
unlinks them). Part links keep an explicit ``entry_id`` so a link to one
|
||||
of the source object's parts still resolves as a foreign-pool link.
|
||||
|
||||
Both entries are reloaded by the caller: the source drops the task's
|
||||
entities, the target creates them under its own object slug.
|
||||
Raises ValueError when the target is full.
|
||||
"""
|
||||
from copy import deepcopy
|
||||
|
||||
from ..const import MAX_TASKS_PER_OBJECT
|
||||
from .tasks_crud import async_delete_task
|
||||
|
||||
task_data = deepcopy(dict(source.data[CONF_TASKS][task_id]))
|
||||
task_data.pop("ref_no", None)
|
||||
links = task_data.get("consumes_parts")
|
||||
if isinstance(links, list):
|
||||
task_data["consumes_parts"] = [
|
||||
{**link, "entry_id": link.get("entry_id") or source.entry_id} if isinstance(link, dict) else link for link in links
|
||||
]
|
||||
|
||||
src_rd = _get_runtime_data(hass, source.entry_id)
|
||||
src_store = getattr(src_rd, "store", None) if src_rd else None
|
||||
state = deepcopy(src_store.get_task_state(task_id)) if src_store is not None else {}
|
||||
state.pop("next_history_ref", None)
|
||||
for entry in state.get("history") or []:
|
||||
if isinstance(entry, dict):
|
||||
entry.pop("ref_no", None)
|
||||
|
||||
# Group memberships: snapshot, let the delete sweep them, re-add under the target.
|
||||
from ..const import CONF_GROUPS
|
||||
from ..helpers.global_options import get_global_entry
|
||||
|
||||
member_groups: list[str] = []
|
||||
global_entry = get_global_entry(hass)
|
||||
if global_entry is not None:
|
||||
for gid, group in (dict(global_entry.options or global_entry.data).get(CONF_GROUPS) or {}).items():
|
||||
if any(isinstance(r, dict) and r.get("task_id") == task_id for r in group.get("task_refs", [])):
|
||||
member_groups.append(gid)
|
||||
|
||||
existing = target.data.get(CONF_TASKS, {})
|
||||
if len(existing) >= MAX_TASKS_PER_OBJECT:
|
||||
raise ValueError(f"The target object already has the maximum of {MAX_TASKS_PER_OBJECT} tasks")
|
||||
|
||||
await async_delete_task(hass, source, task_id)
|
||||
|
||||
new_data = dict(target.data)
|
||||
new_tasks = dict(new_data.get(CONF_TASKS, {}))
|
||||
new_tasks[task_id] = task_data
|
||||
new_data[CONF_TASKS] = new_tasks
|
||||
obj = dict(new_data.get(CONF_OBJECT, {}))
|
||||
obj["task_ids"] = [*obj.get("task_ids", []), task_id]
|
||||
new_data[CONF_OBJECT] = obj
|
||||
hass.config_entries.async_update_entry(target, data=new_data)
|
||||
|
||||
tgt_rd = _get_runtime_data(hass, target.entry_id)
|
||||
tgt_store = getattr(tgt_rd, "store", None) if tgt_rd else None
|
||||
if tgt_store is not None:
|
||||
tgt_store.put_task_state(task_id, state)
|
||||
await tgt_store.async_save()
|
||||
|
||||
if member_groups and global_entry is not None:
|
||||
options = dict(global_entry.options or global_entry.data)
|
||||
groups = dict(options.get(CONF_GROUPS) or {})
|
||||
for gid in member_groups:
|
||||
group = groups.get(gid)
|
||||
if group is None:
|
||||
continue
|
||||
groups[gid] = {**group, "task_refs": [*group.get("task_refs", []), {"entry_id": target.entry_id, "task_id": task_id}]}
|
||||
options[CONF_GROUPS] = groups
|
||||
hass.config_entries.async_update_entry(global_entry, options=options)
|
||||
|
||||
Reference in New Issue
Block a user