217 files

This commit is contained in:
Home Assistant Version Control
2026-07-30 23:59:38 +00:00
parent d43a63ad29
commit 7b5e46e702
217 changed files with 15978 additions and 3912 deletions
@@ -52,6 +52,7 @@ from ..helpers.task_fields import (
EARLIEST_COMPLETION_RANGE,
INTERVAL_ANCHORS,
INTERVAL_DAYS_RANGE,
REQUIRABLE_COMPLETION_FIELDS,
ROTATION_STRATEGY_VALUES,
TASK_PRIORITIES,
WARNING_DAYS_RANGE,
@@ -94,6 +95,7 @@ TASK_UPDATE_FIELD_MAP = {
"documentation_url": "documentation_url",
"responsible_user_id": "responsible_user_id",
"assignee_pool": "assignee_pool",
"required_completion_fields": "required_completion_fields",
"rotation_strategy": "rotation_strategy",
"entity_slug": "entity_slug",
"custom_icon": "custom_icon",
@@ -140,6 +142,7 @@ TASK_UPDATE_FIELD_MAP = {
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),
@@ -283,6 +286,12 @@ async def ws_create_task(
task_data["assignee_pool"] = sanitize_assignee_pool(msg["assignee_pool"])
if msg.get("rotation_strategy"):
task_data["rotation_strategy"] = msg["rotation_strategy"]
if msg.get("required_completion_fields"):
from ..helpers.completion_requirements import sanitize_required_completion_fields
task_data["required_completion_fields"] = sanitize_required_completion_fields(
msg["required_completion_fields"]
)
from ..helpers.sanitize import seed_rotation_assignee
seed_rotation_assignee(task_data)
@@ -313,8 +322,13 @@ async def ws_create_task(
if msg.get("consumes_parts") is not None:
from ..const import CONF_PARTS
from ..helpers.parts import sanitize_consumes_parts
from . import foreign_part_resolver
links = sanitize_consumes_parts(msg["consumes_parts"], set(entry.data.get(CONF_PARTS) or {}))
links = sanitize_consumes_parts(
msg["consumes_parts"],
set(entry.data.get(CONF_PARTS) or {}),
foreign_part_ids=foreign_part_resolver(hass),
)
if links:
task_data["consumes_parts"] = links
if msg.get("checklist"):
@@ -392,6 +406,7 @@ async def ws_create_task(
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),
@@ -500,6 +515,22 @@ async def ws_update_task(
if msg_key in msg:
task[data_key] = msg[msg_key]
# 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
# persist a link to a part — or, since #111, to an OBJECT — that does not
# exist. Nothing complained: the consume path simply skipped it. Sanitize
# here so both write paths agree.
if "consumes_parts" in msg:
from ..const import CONF_PARTS
from ..helpers.parts import sanitize_consumes_parts
from . import foreign_part_resolver
task["consumes_parts"] = sanitize_consumes_parts(
msg["consumes_parts"],
set(entry.data.get(CONF_PARTS) or {}),
foreign_part_ids=foreign_part_resolver(hass),
)
# 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
@@ -532,9 +563,18 @@ async def ws_update_task(
# schedule; drop the stray flat schedule_type so normalize stays clean.
task.pop("schedule_type", None)
# Validate/cap newly-applied v1.3.0 fields. cap_task_fields runs the
# full task sanitize (caches, lengths, action shape) so update-path
# behaves identically to create-path.
# Validate/cap the newly-applied fields. NOTE: this deliberately does NOT
# call cap_task_fields() — the `task/update` schema above already
# length/range-caps every scalar field the sanitiser covers (name,
# task_type, schedule_type, interval_days, warning_days, notes,
# documentation_url, priority, checklist, labels, …), which is the
# WS-boundary contract documented in helpers/sanitize. What the schema
# *can't* express is the shape of the loose dicts and the dedup/trim/seed
# rules on the list fields — exactly the sub-helpers called below. Adding
# cap_task_fields here would be a no-op on everything else and would swap
# the WS layer's reject-at-boundary semantics for silent clamping.
# (The service path in tasks_persist.py DOES call cap_task_fields: those
# functions are reachable directly from Python, not only behind a schema.)
from ..helpers.sanitize import (
cap_action_field,
cap_quick_complete_defaults_field,
@@ -549,6 +589,10 @@ async def ws_update_task(
task["labels"] = sanitize_labels(task["labels"])
if "assignee_pool" in task:
task["assignee_pool"] = sanitize_assignee_pool(task["assignee_pool"])
if "required_completion_fields" in task:
from ..helpers.completion_requirements import sanitize_required_completion_fields
task["required_completion_fields"] = sanitize_required_completion_fields(task["required_completion_fields"])
seed_rotation_assignee(task)
# Clear stale trigger runtime in Store only when trigger fundamentally changes