393 files
This commit is contained in:
1 parent
b05b7a897e
commit
39d97ac2db
404 files changed
+54006
-11034
No files matched your search
@@ -29,13 +29,17 @@ from ..const import (
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
ScheduleType,
|
||||
)
|
||||
from .dates import add_interval, parse_iso_date
|
||||
from .schedule import _CALENDAR_KINDS, Schedule
|
||||
from .dates import parse_iso_date, try_add_interval
|
||||
from .pause import is_task_inert
|
||||
from .status import effective_warning_days
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..models.maintenance_task import MaintenanceTask
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VacationState:
|
||||
@@ -56,7 +60,14 @@ class VacationState:
|
||||
"""Last day on which suppression still applies (inclusive)."""
|
||||
if self.end is None:
|
||||
return None
|
||||
return self.end + timedelta(days=max(0, self.buffer_days))
|
||||
try:
|
||||
return self.end + timedelta(days=max(0, self.buffer_days))
|
||||
except OverflowError:
|
||||
# An end date of 9999-12-31 plus the buffer overflowed, and
|
||||
# is_silent_for runs inside every object's refresh — the object
|
||||
# went unavailable for the whole vacation (bug audit 2026-09-27).
|
||||
# The window simply never ends.
|
||||
return date.max
|
||||
|
||||
def is_active(self, at: datetime | None = None) -> bool:
|
||||
"""True if today falls within [start, end + buffer] and the toggle is on."""
|
||||
@@ -204,16 +215,51 @@ def _project_time_based(
|
||||
window_end: date,
|
||||
interval_unit: str | None = None,
|
||||
) -> list[PreviewEvent]:
|
||||
"""Project DUE_SOON / OVERDUE dates for a time-based task."""
|
||||
"""Project DUE_SOON / OVERDUE dates for a bare interval (no task model).
|
||||
|
||||
Kept for callers that only hold the flat numbers; the preview itself goes
|
||||
through :func:`task_next_due` so postpones, seasons and the planned anchor
|
||||
are honoured.
|
||||
"""
|
||||
if not interval_days or interval_days <= 0:
|
||||
return []
|
||||
anchor = last_performed or created_at or today
|
||||
# Unit-aware (weeks/months/years), not raw days — else a 6-month task would
|
||||
# preview as due in 6 days during vacation planning.
|
||||
next_due = add_interval(anchor, interval_days, interval_unit or "days")
|
||||
# preview as due in 6 days during vacation planning. A due date past the
|
||||
# calendar's end is no event (bug audit 2026-09-27).
|
||||
next_due = try_add_interval(anchor, interval_days, interval_unit or "days")
|
||||
if next_due is None:
|
||||
return []
|
||||
return _events_from_next_due(next_due, warning_days, today, window_start, window_end)
|
||||
|
||||
|
||||
def task_next_due(task: MaintenanceTask, today: date) -> date | None:
|
||||
"""``MaintenanceTask.next_due`` evaluated for an explicit *today*.
|
||||
|
||||
The preview used to re-derive the due date from the flat interval fields
|
||||
and so ignored a postpone (``due_override``), the seasonal window, the
|
||||
planned anchor, a finite series and every one-time task (bug audit
|
||||
2026-09-26, DRY BR-A1). It now asks the task's own Schedule with exactly
|
||||
the inputs the model property uses; only *today* (the first-time anchor)
|
||||
is injectable. ``tests/test_audit_2026_09_26_t2_runtime.py`` pins this to
|
||||
the property for today == now.
|
||||
"""
|
||||
last: date | None = None
|
||||
if task.last_performed:
|
||||
try:
|
||||
last = date.fromisoformat(task.last_performed)
|
||||
except (ValueError, TypeError):
|
||||
return None # the model's "malformed anchor = no next_due" rule
|
||||
return task._schedule().next_due(
|
||||
last_performed=last,
|
||||
created_at=parse_iso_date(task.created_at),
|
||||
last_planned_due=parse_iso_date(task.last_planned_due),
|
||||
today=today,
|
||||
times_performed=task.times_performed,
|
||||
due_override=parse_iso_date(task.due_override),
|
||||
)
|
||||
|
||||
|
||||
def compute_preview(
|
||||
state: VacationState,
|
||||
tasks: Iterable[Mapping[str, Any]],
|
||||
@@ -221,15 +267,18 @@ def compute_preview(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Project status changes for each task during [start, end+buffer].
|
||||
|
||||
Each input *task* dict must carry: ``task_id``, ``entry_id``,
|
||||
``object_name``, ``task_name``, ``schedule_type``, plus the dynamic
|
||||
fields ``last_performed``, ``created_at``, ``interval_days``,
|
||||
``warning_days``, ``enabled`` (all optional).
|
||||
Each input *task* is the task's merged storage dict (static config +
|
||||
Store state, exactly what ``MaintenanceTask.from_dict`` reads) plus the
|
||||
row identity ``task_id``, ``entry_id``, ``object_name``, ``task_name``.
|
||||
The caller drops tasks of paused objects (it holds the object dict);
|
||||
archived and disabled tasks are dropped here as well.
|
||||
|
||||
Returns a list of preview rows; tasks with no projected events in the
|
||||
window are omitted (caller may still want to display the task list
|
||||
elsewhere).
|
||||
"""
|
||||
from ..models.maintenance_task import MaintenanceTask # models import helpers
|
||||
|
||||
if state.start is None or state.end is None:
|
||||
return []
|
||||
today = today or dt_util.now().date()
|
||||
@@ -240,59 +289,41 @@ def compute_preview(
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for t in tasks:
|
||||
if not t.get("enabled", True):
|
||||
# Archived / disabled tasks never fire — the preview listed archived
|
||||
# ones as "will be due" (bug audit 2026-09-26). Paused objects are the
|
||||
# caller's half of the same predicate.
|
||||
if is_task_inert(dict(t), {}):
|
||||
continue
|
||||
task_id = str(t.get("task_id") or "")
|
||||
if not task_id:
|
||||
continue
|
||||
schedule_type = t.get("schedule_type") or "time_based"
|
||||
task = MaintenanceTask.from_dict(dict(t))
|
||||
schedule_type = task.schedule_type
|
||||
|
||||
events: list[PreviewEvent] = []
|
||||
kind: str
|
||||
confidence: str
|
||||
|
||||
if schedule_type == "time_based":
|
||||
events = _project_time_based(
|
||||
last_performed=_coerce_date(t.get("last_performed")),
|
||||
created_at=_coerce_date(t.get("created_at")),
|
||||
interval_days=t.get("interval_days"),
|
||||
warning_days=_task_warning_days(t),
|
||||
today=today,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
interval_unit=t.get("interval_unit"),
|
||||
)
|
||||
kind = "time_based"
|
||||
confidence = "deterministic"
|
||||
elif schedule_type == "sensor_based":
|
||||
if schedule_type == ScheduleType.SENSOR_BASED:
|
||||
# Sensor triggers are non-deterministic. Surface every sensor task
|
||||
# in the window with a single "may fire anytime" event so the user
|
||||
# can decide per-task whether to exempt or pre-complete.
|
||||
events = [PreviewEvent(date=window_start, status="triggered_est")]
|
||||
kind = "sensor_based"
|
||||
confidence = "unpredictable"
|
||||
elif schedule_type in _CALENDAR_KINDS:
|
||||
# Calendar kinds (weekdays / nth_weekday / day_of_month / calendar
|
||||
# entity): project the next occurrence via the Schedule (the flat
|
||||
# fields can't express it).
|
||||
raw = t.get("schedule")
|
||||
sched = Schedule.from_dict(raw) if isinstance(raw, dict) else None
|
||||
nd = (
|
||||
sched.next_due(
|
||||
last_performed=_coerce_date(t.get("last_performed")),
|
||||
created_at=_coerce_date(t.get("created_at")),
|
||||
last_planned_due=None,
|
||||
today=today,
|
||||
)
|
||||
if sched
|
||||
else None
|
||||
)
|
||||
events = _events_from_next_due(nd, _task_warning_days(t), today, window_start, window_end) if nd else []
|
||||
else:
|
||||
# Interval, calendar kinds and one-time tasks: the task's own next
|
||||
# due date and its span-capped warning window — the same inputs
|
||||
# the status ladder uses, so a weekly task with a 14-day warning
|
||||
# doesn't preview as "due soon" for two weeks. Manual tasks (and a
|
||||
# finished series / done one-off) have no next due and drop out.
|
||||
nd = task_next_due(task, today)
|
||||
if nd is None:
|
||||
continue
|
||||
warning = effective_warning_days(_task_warning_days(t), task._schedule().span_days())
|
||||
events = _events_from_next_due(nd, warning, today, window_start, window_end)
|
||||
kind = schedule_type
|
||||
confidence = "deterministic"
|
||||
else:
|
||||
# Manual / one-time tasks have no auto-due — never appear here.
|
||||
continue
|
||||
|
||||
if not events:
|
||||
continue
|
||||
|
||||
Reference in new issue
Block a user