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
@@ -144,6 +144,7 @@ def _build_task_summary(
"archived_reason": task_data.get("archived_reason"),
"responsible_user_id": task_data.get("responsible_user_id"),
"assignee_pool": task_data.get("assignee_pool", []),
"required_completion_fields": task_data.get("required_completion_fields", []),
"rotation_strategy": task_data.get("rotation_strategy"),
"earliest_completion_days": task_data.get("earliest_completion_days"),
"entity_slug": task_data.get("entity_slug"),
@@ -436,6 +437,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_get_budget_status,
ws_get_settings,
ws_get_statistics,
ws_notify_user_targets,
ws_schedule_preview,
ws_subscribe,
ws_test_notification,
@@ -590,6 +592,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_get_settings)
websocket_api.async_register_command(hass, ws_update_global_settings)
websocket_api.async_register_command(hass, ws_test_notification)
websocket_api.async_register_command(hass, ws_notify_user_targets)
websocket_api.async_register_command(hass, ws_list_users)
websocket_api.async_register_command(hass, ws_assign_user)
websocket_api.async_register_command(hass, ws_tasks_by_user)
@@ -605,3 +608,20 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_documents_update)
websocket_api.async_register_command(hass, ws_documents_delete)
websocket_api.async_register_command(hass, ws_documents_search)
def foreign_part_resolver(hass):
"""Callback for ``sanitize_consumes_parts``: which parts an entry owns.
Returns None for an entry that does not exist or is not a maintenance
object, so a link to it is dropped rather than trusted.
"""
from ..const import CONF_PARTS, DOMAIN, GLOBAL_UNIQUE_ID
def _resolve(entry_id: str):
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
return None
return set(entry.data.get(CONF_PARTS) or {})
return _resolve
@@ -192,7 +192,7 @@ async def ws_seasonal_overrides(
# Refresh coordinator
if rd and rd.coordinator:
await rd.coordinator.async_request_refresh()
await rd.coordinator.async_refresh_now()
connection.send_result(msg["id"], {"success": True, "overrides": validated})
@@ -262,7 +262,7 @@ async def ws_set_environmental_entity(
# Refresh coordinator
if rd and rd.coordinator:
await rd.coordinator.async_request_refresh()
await rd.coordinator.async_refresh_now()
connection.send_result(
msg["id"],
@@ -36,6 +36,7 @@ from ..const import (
CONF_DEFAULT_WARNING_DAYS,
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
CONF_DISABLED_TEMPLATE_IDS,
CONF_INSTALL_ASSIST_SENTENCES,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
CONF_NOTIFICATION_BUNDLING_ENABLED,
@@ -150,6 +151,10 @@ def _build_full_settings(options: Mapping[str, Any], *, notify_targets: list[str
"notify_targets": notify_targets or [],
"panel_enabled": options.get(CONF_PANEL_ENABLED, DEFAULT_PANEL_ENABLED),
"panel_title": options.get(CONF_PANEL_TITLE, ""),
# Opt-in copy of the shipped Assist sentences into
# <config>/custom_sentences/ (the only place the classic
# conversation agent reads them from).
"install_assist_sentences": options.get(CONF_INSTALL_ASSIST_SENTENCES, False),
},
"notifications": {
"due_soon_enabled": options.get(CONF_NOTIFY_DUE_SOON_ENABLED, True),
@@ -407,9 +412,7 @@ async def ws_get_budget_status(
msg: dict[str, Any],
) -> None:
"""Return current budget status (monthly/yearly spent vs budget)."""
from datetime import datetime as dt_cls
from homeassistant.util import dt as dt_util
from ..helpers.budget import compute_spend
global_entry = _get_global_entry(hass)
global_options: Mapping[str, Any] = (global_entry.options or global_entry.data) if global_entry else {}
@@ -418,41 +421,10 @@ async def ws_get_budget_status(
yearly_budget = float(global_options.get(CONF_BUDGET_YEARLY, 0))
threshold_pct = int(global_options.get(CONF_BUDGET_ALERT_THRESHOLD, 80))
now = dt_util.now()
monthly_spent = 0.0
yearly_spent = 0.0
entries = _get_object_entries(hass)
for entry in entries:
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
for tid in entry.data.get("tasks", {}):
if store is not None:
history = store.get_history(tid)
else:
history = entry.data.get("tasks", {}).get(tid, {}).get("history", [])
for h_entry in history:
if h_entry.get("type") != "completed":
continue
cost = h_entry.get("cost")
if not isinstance(cost, (int, float)):
continue
ts = h_entry.get("timestamp", "")
try:
entry_dt = dt_cls.fromisoformat(ts)
except (ValueError, TypeError):
continue
# Naive timestamps from older entries: treat as HA local TZ,
# then normalise so year/month boundaries match `now`.
if entry_dt.tzinfo is None:
entry_dt = entry_dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
entry_dt = dt_util.as_local(entry_dt)
if entry_dt.year == now.year:
yearly_spent += cost
if entry_dt.month == now.month:
monthly_spent += cost
# Shared with the coordinator's budget cache (which drives the ALERT), so
# the number the panel draws and the number that triggers the notification
# are the same number by construction.
monthly_spent, yearly_spent = compute_spend(hass)
currency_code = str(global_options.get(CONF_BUDGET_CURRENCY, DEFAULT_BUDGET_CURRENCY))
currency_symbol = BUDGET_CURRENCIES.get(currency_code, "")
@@ -651,7 +623,12 @@ async def ws_update_global_settings(
# ---------------------------------------------------------------------------
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/global/test_notification"})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/global/test_notification",
vol.Optional("user_id"): vol.Any(str, None),
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_test_notification(
@@ -659,7 +636,12 @@ async def ws_test_notification(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Send a test notification using the configured service."""
"""Send a test notification to the household service or to ONE member.
With ``user_id`` the send goes through the same per-user resolution the
real reminders use, so a green result actually proves that member's phone
is reachable.
"""
from ..config_flow_options_global import (
_get_test_result_text,
send_test_notification,
@@ -671,11 +653,47 @@ async def ws_test_notification(
return
options = dict(global_entry.options or global_entry.data)
result_key = await send_test_notification(hass, options)
result_key = await send_test_notification(hass, options, user_id=msg.get("user_id"))
connection.send_result(
msg["id"],
{
"success": result_key == "success",
"result": result_key,
"message": _get_test_result_text(hass, result_key),
},
)
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/notify/user_targets"})
@websocket_api.require_admin
@websocket_api.async_response
async def ws_notify_user_targets(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Report which notify services each household member resolves to.
Answers the question the settings page could not previously answer: "will
Bob actually get his reminders?". Resolution runs through the very same
helper the reminder path uses, so what is shown is what will be used — a
separate lookup here would be able to disagree with reality, which is the
failure mode that made the wrong-service bug behind #75 invisible.
Admin-only: the resolved service names carry members' device names.
"""
from ..helpers.notification_manager import get_user_notify_services
targets: list[dict[str, Any]] = []
for user in await hass.auth.async_get_users():
if not user.is_active or user.system_generated:
continue
targets.append(
{
"user_id": user.id,
"name": user.name,
"services": await get_user_notify_services(hass, user.id),
}
)
connection.send_result(msg["id"], {"targets": targets})
@@ -17,7 +17,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_OBJECT, CONF_TASKS, DOMAIN, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..const import CONF_OBJECT, CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..helpers.integration_signatures import (
SIGNATURES,
build_setup_trigger,
@@ -115,7 +115,11 @@ async def ws_adopt_integration_setups(
objects_created += 1
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN:
# Same three-part guard as websocket._load_object_entry: the global
# settings entry is NOT a valid adoption target — async_persist_task
# writes CONF_TASKS + CONF_OBJECT["task_ids"] into whatever entry it
# is handed, so a client-supplied global entry_id would corrupt it.
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
errors.append({"device_id": device_id, "reason": "target object not found"})
continue
@@ -525,6 +525,7 @@ async def ws_import_json(
"on_complete_action",
"quick_complete_defaults",
"assignee_pool",
"required_completion_fields",
"rotation_strategy",
"reading_unit",
# spare parts (ids remapped below)
@@ -538,11 +539,23 @@ async def ws_import_json(
# Remap part links to the regenerated part ids; drop dangling ones.
links = task_data.get("consumes_parts")
if isinstance(links, list):
remapped = [
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
for link in links
if isinstance(link, dict) and link.get("part_id") in part_id_map
]
remapped = []
for link in links:
if not isinstance(link, dict):
continue
foreign = str(link.get("entry_id") or "").strip()
if foreign:
# A link to another object's pool (#111). Import mints
# new entry ids, so the reference only means anything
# if that object is present in THIS instance — keep it
# then, drop it otherwise rather than restore a link
# that points nowhere.
if hass.config_entries.async_get_entry(foreign) is not None:
remapped.append(dict(link))
elif link.get("part_id") in part_id_map:
remapped.append(
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
)
if remapped:
task_data["consumes_parts"] = remapped
else:
@@ -33,6 +33,7 @@ from ..const import (
MAX_TEXT_LENGTH,
MAX_URL_LENGTH,
)
from ..helpers.pause import reanchor_recurring_task
from ..helpers.permissions import require_write
from ..helpers.sanitize import cap_object_fields
from . import (
@@ -691,15 +692,10 @@ async def ws_unarchive_object(
td.pop("archived_at", None)
td.pop("archived_reason", None)
# Fresh cycle for recurring tasks (D2); last_performed is dynamic →
# Store when present, else the static dict (legacy).
# Store when present, else the static dict (legacy). Shared core so
# this path can't drift from resume/task-unarchive.
if _is_recurring_schedule(td):
if store is not None:
store.set_last_performed(tid, today_iso)
state = store._ensure_task(tid)
state.pop("last_planned_due", None)
else:
td["last_performed"] = today_iso
td.pop("last_planned_due", None)
reanchor_recurring_task(tid, store=store, today_iso=today_iso, task_data=td)
new_tasks[tid] = td
new_data = dict(entry.data)
@@ -904,11 +900,19 @@ async def ws_replace_object(
task["trigger_config"].pop("_trigger_state", None)
links = task.get("consumes_parts")
if isinstance(links, list):
remapped = [
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
for link in links
if isinstance(link, dict) and link.get("part_id") in part_id_map
]
remapped = []
for link in links:
if not isinstance(link, dict):
continue
if link.get("entry_id"):
# A pool owned by ANOTHER object (#111) is untouched by
# replacing this one — carry the link across verbatim, ids
# and all, or the successor silently stops consuming it.
remapped.append(dict(link))
elif link.get("part_id") in part_id_map:
remapped.append(
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
)
if remapped:
task["consumes_parts"] = remapped
else:
@@ -15,7 +15,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_OBJECT, DOMAIN, MAX_ENTITY_ID_LENGTH, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..const import CONF_OBJECT, DOMAIN, GLOBAL_UNIQUE_ID, MAX_ENTITY_ID_LENGTH, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..helpers.permissions import require_write
from ..helpers.problem_sensors import (
build_problem_task,
@@ -106,7 +106,11 @@ async def ws_adopt_problem_sensors(
device_to_entry[device_id] = entry_id
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN:
# Same three-part guard as websocket._load_object_entry: the global
# settings entry is NOT a valid adoption target — async_persist_task
# writes CONF_TASKS + CONF_OBJECT["task_ids"] into whatever entry it
# is handed, so a client-supplied global entry_id would corrupt it.
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
errors.append({"entity_id": entity_id, "reason": "target object not found"})
continue
@@ -7,6 +7,7 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from ..const import (
CONF_TASKS,
@@ -105,6 +106,11 @@ def _completion_blocked(rd: Any, task_id: str) -> bool:
vol.Schema(
{
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
# #111: the pool may live on another object. The
# schema has to allow it or voluptuous rejects the
# completion before the handler (which already
# validates the reference) ever runs.
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("quantity", default=1): vol.All(
vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=999)
),
@@ -143,21 +149,38 @@ async def ws_complete_task(
used_parts = msg.get("used_parts")
if used_parts is not None:
from ..helpers.parts import sanitize_consumes_parts
from . import foreign_part_resolver
used_parts = sanitize_consumes_parts(used_parts, set(_entry.data.get("parts") or {}))
used_parts = sanitize_consumes_parts(
used_parts,
set(_entry.data.get("parts") or {}),
foreign_part_ids=foreign_part_resolver(hass),
)
await rd.coordinator.complete_maintenance(
task_id=msg["task_id"],
notes=msg.get("notes"),
cost=msg.get("cost"),
duration=msg.get("duration"),
checklist_state=msg.get("checklist_state"),
feedback=msg.get("feedback"),
photo_doc_id=msg.get("photo_doc_id"),
reading_value=msg.get("reading_value"),
restock_quantity=msg.get("restock_quantity"),
used_parts=used_parts,
)
try:
await rd.coordinator.complete_maintenance(
task_id=msg["task_id"],
notes=msg.get("notes"),
cost=msg.get("cost"),
duration=msg.get("duration"),
checklist_state=msg.get("checklist_state"),
feedback=msg.get("feedback"),
photo_doc_id=msg.get("photo_doc_id"),
reading_value=msg.get("reading_value"),
restock_quantity=msg.get("restock_quantity"),
used_parts=used_parts,
# Who did it: taken from the authenticated connection, never from
# the payload — a client must not be able to credit someone else.
# This is also what feeds the `least_completed` rotation strategy
# and what satisfies a task requiring the "user" detail.
completed_by=connection.user.id if connection.user else None,
)
except ServiceValidationError as err:
# Required completion details are missing. The dialog normally
# prevents this, so reaching here means an older/cached frontend or a
# scripted call — answer with the field list rather than a traceback.
connection.send_error(msg["id"], "completion_details_required", str(err))
return
connection.send_result(msg["id"], {"success": True})
@@ -212,13 +235,20 @@ async def ws_quick_complete_task(
)
return
await rd.coordinator.complete_maintenance(
task_id=msg["task_id"],
notes=defaults.get("notes"),
cost=defaults.get("cost"),
duration=defaults.get("duration"),
feedback=defaults.get("feedback"),
)
try:
await rd.coordinator.complete_maintenance(
task_id=msg["task_id"],
notes=defaults.get("notes"),
cost=defaults.get("cost"),
duration=defaults.get("duration"),
feedback=defaults.get("feedback"),
completed_by=connection.user.id if connection.user else None,
)
except ServiceValidationError as err:
# The task demands details the quick-complete defaults do not cover —
# same fallback as `no_defaults`: the caller opens the full dialog.
connection.send_error(msg["id"], "completion_details_required", str(err))
return
connection.send_result(msg["id"], {"success": True, "via": "quick"})
@@ -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
@@ -151,7 +151,7 @@ async def ws_update_history_entry(
# Refresh coordinator + budget cache so the UI reflects the change
if rd and rd.coordinator:
rd.coordinator._recalculate_budget_cache()
await rd.coordinator.async_request_refresh()
await rd.coordinator.async_refresh_now()
connection.send_result(
msg["id"],
@@ -16,6 +16,7 @@ from ..const import (
CONF_TASKS,
MAX_ID_LENGTH,
)
from ..helpers.pause import clear_cycle_modifiers, reanchor_recurring_task
from ..helpers.permissions import require_write
from . import (
_build_task_summary,
@@ -128,13 +129,14 @@ async def ws_unarchive_task(
# class, bug audit 2026-07-11).
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
recurring = _is_recurring_schedule(td)
legacy_anchor = False
if _is_recurring_schedule(td):
if recurring:
today_iso = dt_util.now().date().isoformat()
if store is not None:
store.set_last_performed(task_id, today_iso)
state = store._ensure_task(task_id)
state.pop("last_planned_due", None)
# Dynamic half only — the static half runs after the fresh re-read
# below, so the await stays BEFORE that read (see the note above).
reanchor_recurring_task(task_id, store=store, today_iso=today_iso)
await store.async_save()
else:
legacy_anchor = True
@@ -148,8 +150,13 @@ async def ws_unarchive_task(
td.pop("archived_at", None)
td.pop("archived_reason", None)
if legacy_anchor:
td["last_performed"] = dt_util.now().date().isoformat()
td.pop("last_planned_due", None)
reanchor_recurring_task(
task_id, store=None, today_iso=dt_util.now().date().isoformat(), task_data=td
)
elif recurring:
# The Store already holds the fresh anchor; scrub the static shadow too
# so an imported due_override can't out-rank it in merge_task_data.
clear_cycle_modifiers(td)
new_data = dict(entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
@@ -17,6 +17,7 @@ from ..const import (
GLOBAL_UNIQUE_ID,
MAX_TASKS_PER_OBJECT,
)
from ..helpers.sanitize import cap_task_fields
from ..helpers.schedule import (
normalize_task_storage,
)
@@ -109,6 +110,11 @@ async def async_create_task_simple(
Raises ValueError if the entry_id is not a maintenance object or the name
is empty.
Like the config-flow save handlers (see ``helpers/sanitize``), this runs
:func:`cap_task_fields` before persisting: the ``add_task`` *service*
schema is the boundary for service callers, but this function is also
reachable directly from Python, so the caps can't live only in the schema.
"""
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
@@ -138,6 +144,10 @@ async def async_create_task_simple(
task_data["due_date"] = due_date
if notes:
task_data["notes"] = notes
# Same sanitising as the config-flow create path, applied BEFORE the
# storage normalisation inside async_persist_task so a capped
# 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"]
@@ -172,6 +182,10 @@ async def async_update_task_simple(
:func:`normalize_task_storage`, so partial edits keep the unit/anchor
semantics of the storage model (issue #58 class).
Runs :func:`cap_task_fields` over the MERGED task before persisting —
mirroring the options-flow edit path — so a direct Python caller can't
write past the caps the ``update_task`` service schema enforces.
Raises ValueError for an unknown entry/task or an empty name.
"""
entry = hass.config_entries.async_get_entry(entry_id)
@@ -196,6 +210,7 @@ async def async_update_task_simple(
if updates.get("schedule"):
task["schedule"] = updates["schedule"]
cap_task_fields(task)
new_tasks[task_id] = normalize_task_storage(task)
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
@@ -117,7 +117,7 @@ async def ws_assign_user(
# Refresh coordinator
rd = _get_runtime_data(hass, entry.entry_id)
if rd and rd.coordinator:
await rd.coordinator.async_request_refresh()
await rd.coordinator.async_refresh_now()
connection.send_result(msg["id"], {"success": True, "user_id": user_id})