207 lines
7.6 KiB
Python
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)
|