217 files
This commit is contained in:
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user