393 files
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -10,21 +11,22 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import (
|
||||
CONF_OBJECT,
|
||||
CONF_VACATION_BUFFER_DAYS,
|
||||
CONF_VACATION_ENABLED,
|
||||
CONF_VACATION_END,
|
||||
CONF_VACATION_EXEMPT_TASK_IDS,
|
||||
CONF_VACATION_START,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DOMAIN,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_INTERVAL_DAYS,
|
||||
MAX_VACATION_EXEMPT_TASKS,
|
||||
)
|
||||
from ..helpers.aggregate import object_name
|
||||
from ..helpers.dates import parse_iso_date
|
||||
from ..helpers.schedule import read_legacy_fields
|
||||
from ..helpers.pause import is_task_inert
|
||||
from ..helpers.vacation import compute_preview, get_vacation_state
|
||||
from . import _get_merged_tasks, _get_object_entries, _load_global_options, _parse_iso_date, _save_global_options
|
||||
from . import ID_FIELD, _get_merged_tasks, _get_object_entries, _load_global_options, _parse_iso_date, _save_global_options
|
||||
|
||||
|
||||
def _state_payload(hass: HomeAssistant) -> dict[str, Any]:
|
||||
@@ -51,7 +53,7 @@ async def ws_vacation_state(
|
||||
vol.Optional("end"): vol.Any(vol.All(str, vol.Length(max=10)), None),
|
||||
vol.Optional("buffer_days"): vol.All(int, vol.Range(min=0, max=14)),
|
||||
vol.Optional("exempt_task_ids"): vol.All(
|
||||
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
[ID_FIELD],
|
||||
vol.Length(max=MAX_VACATION_EXEMPT_TASKS),
|
||||
),
|
||||
}
|
||||
@@ -72,11 +74,24 @@ async def ws_vacation_update(
|
||||
if "enabled" in msg:
|
||||
options[CONF_VACATION_ENABLED] = bool(msg["enabled"])
|
||||
|
||||
# A vacation is a stretch of days, not a lifetime: a date more than
|
||||
# MAX_INTERVAL_DAYS (10 years) out is refused. 9999-12-31 plus the
|
||||
# return buffer overflowed the calendar inside every object's refresh
|
||||
# and took the objects down for the whole "vacation" (bug audit
|
||||
# 2026-09-27; helpers.vacation now also clamps what is already stored).
|
||||
latest = dt_util.now().date() + timedelta(days=MAX_INTERVAL_DAYS)
|
||||
for field, key in (("start", CONF_VACATION_START), ("end", CONF_VACATION_END)):
|
||||
if field not in msg:
|
||||
continue
|
||||
if msg[field] is not None and _parse_iso_date(connection, msg["id"], msg[field], field=field) is None:
|
||||
return
|
||||
if msg[field] is not None:
|
||||
parsed = _parse_iso_date(connection, msg["id"], msg[field], field=field)
|
||||
if parsed is None:
|
||||
return
|
||||
if parsed > latest:
|
||||
connection.send_error(
|
||||
msg["id"], "invalid_range", f"{field} must be within {MAX_INTERVAL_DAYS} days from today"
|
||||
)
|
||||
return
|
||||
options[key] = msg[field]
|
||||
|
||||
# End-vs-start sanity (only when both are present after the patch; a
|
||||
@@ -133,34 +148,26 @@ async def ws_vacation_preview(
|
||||
connection.send_result(msg["id"], {"rows": [], "window_end": None})
|
||||
return
|
||||
|
||||
# Build the flat task list expected by compute_preview.
|
||||
# Hand the preview each task's MERGED dict (static + Store) so it builds
|
||||
# the real MaintenanceTask — a hand-picked flat subset dropped
|
||||
# due_override, the seasonal window, the planned anchor and one-time due
|
||||
# dates (bug audit 2026-09-26, DRY BR-A1). Inert tasks (archived,
|
||||
# disabled, paused object) fire nothing during the absence either, so
|
||||
# they are no preview rows.
|
||||
tasks: list[dict[str, Any]] = []
|
||||
for entry in _get_object_entries(hass):
|
||||
obj_name = object_name(entry)
|
||||
# Merge dynamic store fields (last_performed, etc.) when available.
|
||||
merged = _get_merged_tasks(entry)
|
||||
|
||||
for task_id, task_data in merged.items():
|
||||
sched = read_legacy_fields(task_data)
|
||||
obj = entry.data.get(CONF_OBJECT, {})
|
||||
for task_id, task_data in _get_merged_tasks(entry).items():
|
||||
if is_task_inert(task_data, obj):
|
||||
continue
|
||||
tasks.append(
|
||||
{
|
||||
**task_data,
|
||||
"task_id": task_id,
|
||||
"entry_id": entry.entry_id,
|
||||
"object_name": obj_name,
|
||||
"task_name": task_data.get("name", ""),
|
||||
"schedule_type": sched["schedule_type"],
|
||||
"interval_days": sched["interval_days"],
|
||||
"interval_unit": sched["interval_unit"],
|
||||
# Nested schedule so the preview can project calendar kinds.
|
||||
"schedule": task_data.get("schedule"),
|
||||
"warning_days": task_data.get("warning_days", DEFAULT_WARNING_DAYS),
|
||||
"last_performed": task_data.get("last_performed"),
|
||||
"created_at": task_data.get("created_at"),
|
||||
"enabled": task_data.get("enabled", True),
|
||||
# The preview's Skip button must follow the task's own
|
||||
# rule — the row had no field and offered it to every
|
||||
# time-based task (bug review 2026-09-04).
|
||||
"allow_skip": task_data.get("allow_skip") is not False,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user