Files
HomeAssistantVS/custom_components/maintenance_supporter/helpers/retention.py
T

246 lines
9.1 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
from .dates import local_date_from_iso
_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 stamp's LOCAL
calendar day is all the day-granular policy needs.
"""
day = local_date_from_iso(value)
if day is None:
return None
try:
return date.fromisoformat(day)
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 .global_options import get_global_options
return dict(get_global_options(hass))
def _merged_tasks(entry: Any) -> dict[str, Any]:
"""Static (ConfigEntry) + dynamic (Store) task data for an object entry."""
from .aggregate import merged_tasks
return merged_tasks(entry)
async def async_sweep_unattached_photos(hass: HomeAssistant) -> int:
"""Remove completion photos that never became part of a record.
A document tagged exactly ``photo``, pointed at by nothing (no history
entry of any task, no part, no task link) and uploaded more than
``UNATTACHED_PHOTO_MAX_AGE_HOURS`` ago: the dialog it was taken in was
cancelled, the photo removed again, or the upload finished after the
save. Those orphans used to stay forever and count against the object's
document cap (bug audit 2026-09-27, R SEC-3). Skipped entirely while an
object's Store is not loaded — its history could reference a photo.
Returns the number removed.
"""
from datetime import timedelta
from ..const import DOCUMENT_STORE_KEY, DOMAIN
from .completion_requirements import UNATTACHED_PHOTO_MAX_AGE_HOURS, unattached_photo_ids
from .dates import parse_persisted_utc
store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
if store is None:
return 0
candidates = unattached_photo_ids(hass)
if not candidates:
return 0
cutoff = dt_util.utcnow() - timedelta(hours=UNATTACHED_PHOTO_MAX_AGE_HOURS)
removed = 0
for doc_id in candidates:
doc = store.documents.get(doc_id)
added = parse_persisted_utc(doc.get("added_at")) if doc is not None else None
# No readable upload time = no age: kept (the hygiene scan's business).
if added is None or added > cutoff:
continue
await store.async_remove(doc_id)
removed += 1
if removed:
_LOGGER.info("Removed %d completion photo(s) that never became part of a record", removed)
return removed
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
# Independent of the archive / delete thresholds: a completion photo that
# never became part of a record is not a retention choice but an orphan.
await async_sweep_unattached_photos(hass)
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:
from .entry_tasks import write_tasks
existing = entry.data.get(CONF_TASKS, {})
updates: dict[str, dict[str, Any]] = {}
for tid in to_archive:
if tid not in existing:
continue
td = dict(existing[tid])
td["archived_at"] = now_iso
td["archived_reason"] = _REASON_AUTO
updates[tid] = td
write_tasks(hass, entry, updates)
_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)