393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -13,14 +13,17 @@ from ..const import (
BATTERY_FLEET_TASK_FLAG,
CONF_OBJECT,
CONF_TASKS,
DEFAULT_WARNING_DAYS,
DOMAIN,
FLAT_SCHEDULE_TYPES,
MAX_TASKS_PER_OBJECT,
)
from ..helpers.aggregate import get_store, is_object_entry
from ..helpers.dates import parse_iso_date
from ..helpers.entry_tasks import insert_new_task
from ..helpers.global_options import get_default_warning_days
from ..helpers.sanitize import cap_task_fields
from ..helpers.schedule import (
Schedule,
normalize_task_storage,
)
@@ -29,6 +32,20 @@ from ..helpers.schedule import (
# ---------------------------------------------------------------------------
def _checked_due_date(value: Any) -> str:
"""The canonical ISO form of a service-supplied ``due_date``.
The service schemas only require a string, so "next tuesday" was stored
as a one-time task's due date — the task then never came due (bug audit
2026-09-26). Raises ValueError, which the services report as invalid
input.
"""
parsed = parse_iso_date(value) if isinstance(value, str) else None
if parsed is None:
raise ValueError(f"due_date must be a valid date (YYYY-MM-DD), got {value!r}")
return parsed.isoformat()
async def async_persist_task(
hass: HomeAssistant,
entry: ConfigEntry,
@@ -64,7 +81,7 @@ async def async_create_task_simple(
interval_days: int | None = None,
interval_unit: str = "days",
due_date: str | None = None,
warning_days: int = DEFAULT_WARNING_DAYS,
warning_days: int | None = None,
enabled: bool = True,
notes: str | None = None,
schedule: dict[str, Any] | None = None,
@@ -76,8 +93,12 @@ async def async_create_task_simple(
For the full field set (triggers, checklists, completion actions, …) use
the panel / card dialogs or the ``task/create`` WS command.
Raises ValueError if the entry_id is not a maintenance object or the name
is empty.
Raises ValueError if the entry_id is not a maintenance object, the name
is empty or the due date is not a date.
``warning_days`` defaults to the integration-wide setting like every
other create path — it was the bare constant 7 here (bug audit
2026-09-26).
Like the config-flow save handlers (see ``helpers/sanitize``), this runs
:func:`cap_task_fields` before persisting: the ``add_task`` *service*
@@ -85,31 +106,34 @@ async def async_create_task_simple(
reachable directly from Python, so the caps can't live only in the schema.
"""
entry = hass.config_entries.async_get_entry(entry_id)
if not is_object_entry(entry):
# is_object_entry() rejects None as well; the explicit test narrows the type.
if entry is None or not is_object_entry(entry):
raise ValueError(f"No maintenance object found for entry_id {entry_id!r}")
name = (name or "").strip()
if not name:
raise ValueError("Name must not be empty")
task_id = uuid4().hex
task_data: dict[str, Any] = {
"id": uuid4().hex,
"id": task_id,
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
"name": name,
"type": task_type,
"enabled": enabled,
"schedule_type": schedule_type,
"warning_days": warning_days,
"warning_days": warning_days if warning_days is not None else get_default_warning_days(hass),
"created_at": dt_util.now().date().isoformat(),
}
if schedule:
# Calendar kinds: persist the nested schedule (normalize treats it as
# authoritative over the flat fields).
task_data["schedule"] = schedule
# authoritative over the flat fields) — canonicalised like the WS
# create path does; it was stored raw (bug audit 2026-09-26).
task_data["schedule"] = Schedule.from_dict(schedule).to_dict()
if interval_days is not None:
task_data["interval_days"] = interval_days
if interval_unit and interval_unit != "days":
task_data["interval_unit"] = interval_unit
if due_date:
task_data["due_date"] = due_date
task_data["due_date"] = _checked_due_date(due_date)
if notes:
task_data["notes"] = notes
# Same sanitising as the config-flow create path, applied BEFORE the
@@ -117,7 +141,7 @@ async def async_create_task_simple(
# interval_days/warning_days is what the schedule model sees.
cap_task_fields(task_data)
await async_persist_task(hass, entry, task_data)
return task_data["id"]
return task_id
_UPDATABLE_FLAT_FIELDS = (
@@ -157,7 +181,8 @@ async def async_update_task_simple(
Raises ValueError for an unknown entry/task or an empty name.
"""
entry = hass.config_entries.async_get_entry(entry_id)
if not is_object_entry(entry):
# is_object_entry() rejects None as well; the explicit test narrows the type.
if entry is None or not is_object_entry(entry):
raise ValueError(f"No maintenance object found for entry_id {entry_id!r}")
new_data = dict(entry.data)
@@ -166,6 +191,8 @@ async def async_update_task_simple(
raise ValueError(f"No task {task_id!r} in {entry.title!r}")
task = dict(new_tasks[task_id])
if updates.get("due_date") is not None:
updates = {**updates, "due_date": _checked_due_date(updates["due_date"])}
for key in _UPDATABLE_FLAT_FIELDS:
if key in updates and updates[key] is not None:
task[key] = updates[key]
@@ -184,9 +211,23 @@ async def async_update_task_simple(
if updates.get("schedule_type") is not None:
task["schedule_type"] = updates["schedule_type"]
if updates.get("schedule"):
task["schedule"] = updates["schedule"]
task["schedule"] = Schedule.from_dict(updates["schedule"]).to_dict()
elif isinstance(task.get("schedule"), dict) and Schedule.from_dict(task["schedule"]).is_calendar_kind and (
any(updates.get(key) is not None for key in ("interval_days", "interval_unit", "due_date"))
or updates.get("schedule_type") in FLAT_SCHEDULE_TYPES
):
# A flat recurrence edit on a calendar-kind task: normalize keeps a
# calendar schedule authoritative and DROPS the flat keys, so the
# interval the caller set silently vanished. Same rule as the WS
# task/update — the flat fields rebuild the schedule (bug audit
# 2026-09-26).
task.pop("schedule", None)
cap_task_fields(task)
# The service cannot change the completion action, so the stored one keeps
# the user it runs as — dropping ``configured_by`` here made an operator's
# action run with system rights after any service edit (bug audit
# 2026-09-27).
cap_task_fields(task, keep_action_owner=True)
new_tasks[task_id] = normalize_task_storage(task)
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
@@ -277,46 +318,53 @@ async def async_move_task(
if isinstance(pdef, dict) and isinstance(pdef.get("consumes_parts"), list):
pdef["consumes_parts"] = _stamp_part_links(pdef["consumes_parts"], source.entry_id)
state = deepcopy(src_store.get_task_state(task_id))
state.pop("next_history_ref", None)
for entry in state.get("history") or []:
if isinstance(entry, dict):
entry.pop("ref_no", None)
def _strip_refs(state: dict[str, Any]) -> dict[str, Any]:
state.pop("next_history_ref", None)
for entry in state.get("history") or []:
if isinstance(entry, dict):
entry.pop("ref_no", None)
return state
state = _strip_refs(deepcopy(src_store.get_task_state(task_id)))
# 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
from ..helpers.global_options import get_global_entry, get_global_options
from . import _merge_global_options
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)
for gid, group in (get_global_options(hass).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)
# Vacation exemption + document links: the delete leg strips both
# (task-id keyed, otherwise never pruned) — snapshot, restore after.
vacation_exempt = False
if global_entry is not None:
exempt = global_entry.options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
vacation_exempt = isinstance(exempt, list) and task_id in exempt
exempt = get_global_options(hass).get(CONF_VACATION_EXEMPT_TASK_IDS) or []
vacation_exempt = isinstance(exempt, list) and task_id in exempt
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
doc_links = doc_store.task_links(task_id) if doc_store is not None else {}
photo_ids: set[str] = set()
for entry in state.get("history") or []:
if isinstance(entry, dict):
photo_ids.update(history_photo_ids(entry))
# Re-home only the photos that are linked to this task alone — a doc
# shared with the object's other tasks stays where it is (link kept).
rehome_ids = {
did for did in photo_ids if did in doc_links and (doc_store.get(did) or {}).get("task_ids") == [task_id]
}
# Photos linked to this task alone are re-homed with it — a doc shared
# with the object's other tasks stays where it is (link kept). Which of
# them the history uses is decided on the FINAL state below.
sole_task_docs = {did for did in doc_links if (doc_store.get(did) or {}).get("task_ids") == [task_id]}
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)
# The delete awaits before it drops the Store state; take the state as
# it was at that moment — a completion landing during the awaits was
# lost from the snapshot above (bug audit 2026-09-26).
final_state: dict[str, Any] = {}
await async_delete_task(hass, source, task_id, removed_state=final_state)
if final_state:
state = _strip_refs(final_state)
photo_ids: set[str] = set()
for entry in state.get("history") or []:
if isinstance(entry, dict):
photo_ids.update(history_photo_ids(entry))
rehome_ids = photo_ids & sole_task_docs
new_data = dict(target.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
@@ -333,20 +381,18 @@ async def async_move_task(
if doc_store is not None and doc_links:
await doc_store.async_relink_task(task_id, doc_links, rehome_doc_ids=rehome_ids, object_id=task_data["object_id"])
# Both write-backs read the settings the delete leg just rewrote
# (DRY audit 2026-09-26 B: one read rule, one merge).
if vacation_exempt and global_entry is not None:
options = dict(global_entry.options)
current = options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
current = get_global_options(hass).get(CONF_VACATION_EXEMPT_TASK_IDS) or []
if task_id not in current:
options[CONF_VACATION_EXEMPT_TASK_IDS] = [*current, task_id]
hass.config_entries.async_update_entry(global_entry, options=options)
_merge_global_options(hass, global_entry, {CONF_VACATION_EXEMPT_TASK_IDS: [*current, task_id]})
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 {})
groups = dict(get_global_options(hass).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)
_merge_global_options(hass, global_entry, {CONF_GROUPS: groups})