Files
HomeAssistantVS/custom_components/maintenance_supporter/helpers/retention.py
T
2026-07-08 10:43:39 -04:00

207 lines
7.6 KiB
Python

"""Archive & auto-delete retention policy (v2.10.0).
Two parts:
* **Pure decisions** — :func:`should_auto_archive` / :func:`should_auto_delete`
reason over a single (merged) task dict + the global day thresholds + an
injected ``today``. No Home Assistant imports, so every branch is unit-testable
with plain dicts.
* **The sweep** — :func:`async_run_retention_sweep` walks every object entry once
(wired to a daily timer in ``__init__.async_setup``), applies the archives in
one ConfigEntry write per entry, deletes the eligible tasks via the shared
``websocket.tasks.async_delete_task`` helper, and reloads each touched entry so
its entities reflect the new state.
Policy (locked design):
* Auto-archive applies to **completed one-off tasks only** — recurring / sensor
tasks never reach a terminal "done" state, so they are archived manually only.
* Auto-delete applies to **auto-archived** one-offs only (``archived_reason ==
"auto"``). A manually archived item is never auto-deleted — deleting a manual
archive stays an explicit user action.
* Both thresholds are "0 = disabled" (archive) / "0 = never" (delete).
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ..const import ARCHIVE_REASON_AUTO
_LOGGER = logging.getLogger(__name__)
def _to_date(value: Any) -> date | None:
"""Coerce a stored date / ISO timestamp string to a ``date`` (or None).
Accepts both ``"2026-06-24"`` (last_performed) and a full ISO timestamp
``"2026-06-24T12:00:00+00:00"`` (archived_at) — the leading ``YYYY-MM-DD``
is all the day-granular policy needs.
"""
if not isinstance(value, str) or len(value) < 10:
return None
try:
return date.fromisoformat(value[:10])
except ValueError:
return None
def is_completed_oneoff(task: dict[str, Any]) -> bool:
"""True iff ``task`` is a one-off (``one_time`` recurrence) that's been done."""
# Local import keeps this module HA-free for the pure-function tests.
from .schedule import KIND_ONE_TIME, Schedule
return Schedule.parse(task).kind == KIND_ONE_TIME and bool(task.get("last_performed"))
def should_auto_archive(task: dict[str, Any], *, archive_days: int, today: date) -> bool:
"""Decide whether a (merged) task should be auto-archived now.
True only for an active (not-yet-archived) completed one-off whose completion
is at least ``archive_days`` days in the past. ``archive_days <= 0`` disables
auto-archive entirely.
"""
if archive_days <= 0:
return False
if task.get("archived_at") is not None:
return False
if not is_completed_oneoff(task):
return False
last_performed = _to_date(task.get("last_performed"))
if last_performed is None:
return False
return (today - last_performed).days >= archive_days
def should_auto_delete(task: dict[str, Any], *, delete_days: int, today: date) -> bool:
"""Decide whether an auto-archived task should be auto-deleted now.
True only for a task archived **automatically** (``archived_reason == "auto"``
— which, by construction, is always a completed one-off) at least
``delete_days`` days ago. ``delete_days <= 0`` means "never delete". Manual /
object-cascade archives are deliberately excluded.
"""
if delete_days <= 0:
return False
if task.get("archived_at") is None:
return False
if task.get("archived_reason") != ARCHIVE_REASON_AUTO:
return False
archived_on = _to_date(task.get("archived_at"))
if archived_on is None:
return False
return (today - archived_on).days >= delete_days
def _coerce_int(value: Any, default: int) -> int:
"""Best-effort int coercion (settings come through the WS as int already)."""
try:
return int(value)
except (TypeError, ValueError):
return default
def _global_options(hass: HomeAssistant) -> dict[str, Any]:
"""Return the global entry's options (or data), or {} when absent."""
from ..const import DOMAIN, GLOBAL_UNIQUE_ID
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
opts: dict[str, Any] = dict(entry.options or entry.data)
return opts
return {}
def _merged_tasks(entry: Any) -> dict[str, Any]:
"""Static (ConfigEntry) + dynamic (Store) task data for an object entry."""
from ..const import CONF_TASKS
tasks = entry.data.get(CONF_TASKS, {})
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
merged: dict[str, Any] = store.merge_all_tasks(tasks) if store is not None else tasks
return merged
async def async_run_retention_sweep(hass: HomeAssistant) -> None:
"""Auto-archive overdue-done one-offs and auto-delete aged auto-archives.
Idempotent and cheap: only writes / reloads an entry that actually has work
this pass. Safe to call from a daily timer or directly from a test.
"""
from ..const import (
ARCHIVE_REASON_AUTO as _REASON_AUTO,
)
from ..const import (
CONF_ARCHIVE_ONEOFF_DAYS,
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
CONF_TASKS,
DEFAULT_ARCHIVE_ONEOFF_DAYS,
DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS,
)
from .aggregate import get_object_entries
opts = _global_options(hass)
archive_days = _coerce_int(
opts.get(CONF_ARCHIVE_ONEOFF_DAYS, DEFAULT_ARCHIVE_ONEOFF_DAYS),
DEFAULT_ARCHIVE_ONEOFF_DAYS,
)
delete_days = _coerce_int(
opts.get(CONF_DELETE_ARCHIVED_ONEOFF_DAYS, DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS),
DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS,
)
if archive_days <= 0 and delete_days <= 0:
return
today = dt_util.now().date()
now_iso = dt_util.now().isoformat()
for entry in get_object_entries(hass):
merged = _merged_tasks(entry)
# The two sets are disjoint by construction: archive needs archived_at
# None; delete needs archived_at set — a task can't be both this pass.
to_archive = [tid for tid, td in merged.items() if should_auto_archive(td, archive_days=archive_days, today=today)]
to_delete = [tid for tid, td in merged.items() if should_auto_delete(td, delete_days=delete_days, today=today)]
if not to_archive and not to_delete:
continue
if to_archive:
new_tasks = dict(entry.data.get(CONF_TASKS, {}))
for tid in to_archive:
if tid not in new_tasks:
continue
td = dict(new_tasks[tid])
td["archived_at"] = now_iso
td["archived_reason"] = _REASON_AUTO
new_tasks[tid] = td
new_data = dict(entry.data)
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
_LOGGER.info(
"Auto-archived %d completed one-off task(s) in %s",
len(to_archive),
entry.title,
)
if to_delete:
from ..websocket.tasks import async_delete_task
deleted = 0
for tid in to_delete:
if await async_delete_task(hass, entry, tid):
deleted += 1
if deleted:
_LOGGER.info(
"Auto-deleted %d archived one-off task(s) in %s",
deleted,
entry.title,
)
# Reload once so entities reflect the archive (inert) / delete (gone).
await hass.config_entries.async_reload(entry.entry_id)