Files
HomeAssistantVS/custom_components/maintenance_supporter/helpers/saved_views.py
T
2026-07-14 23:57:03 -04:00

151 lines
6.2 KiB
Python

"""Saved filter views — named, shared combinations of the panel list's filters.
A "view" bundles the panel task-list's filter state (status / responsible user /
archived toggle) plus its sort + group-by mode under a user-given name, so a
household or team can reapply "Kitchen overdue" or "Unassigned this week" from
the toolbar in one tap instead of re-picking every control.
Views are **shared/global**: one list stored on the global config entry, listed
by any authenticated user (read) and created/deleted with write permission. A
view's ``id`` is a stable handle a later feature can reference (e.g. notification
routing — "only notify me about view X").
This module is the pure shape + sanitiser; the WS layer (``websocket/saved_views``)
reads/writes the global entry's options. Everything stored is re-sanitised on the
way in, so a hand-edited or legacy entry can never inject unknown keys/values.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from uuid import uuid4
from homeassistant.core import HomeAssistant
from ..const import CONF_SAVED_FILTER_VIEWS, MAX_SAVED_VIEWS, MAX_VIEW_NAME_LENGTH
from .global_options import get_global_options
# Closed value sets mirrored from the panel's filter controls. Anything outside
# these coerces to the permissive default ("" / "due_date" / "none"), so an
# unknown value degrades to "show all / natural order" rather than being stored.
VALID_STATUSES = frozenset({"", "ok", "due_soon", "overdue", "triggered", "paused", "archived"})
VALID_SORT_MODES = frozenset({"due_date", "object", "type", "task_name", "area", "assigned_user", "group"})
VALID_GROUP_BY = frozenset({"none", "area", "group", "user"})
def _clean_filters(raw: Any) -> dict[str, Any]:
"""Sanitise a view's ``filters`` sub-dict against the closed value sets."""
src: Mapping[str, Any] = raw if isinstance(raw, Mapping) else {}
status = src.get("status", "")
status = status if isinstance(status, str) and status in VALID_STATUSES else ""
sort_mode = src.get("sort_mode", "due_date")
sort_mode = sort_mode if isinstance(sort_mode, str) and sort_mode in VALID_SORT_MODES else "due_date"
group_by = src.get("group_by", "none")
group_by = group_by if isinstance(group_by, str) and group_by in VALID_GROUP_BY else "none"
user_id = src.get("user_id")
user_id = user_id.strip() if isinstance(user_id, str) and user_id.strip() else None
if isinstance(user_id, str) and len(user_id) > 64:
user_id = None
# v2.26: a label filter ("only tasks tagged 'garden'") — free text like the
# labels themselves (capped to their length), None = no label filter.
from ..const import MAX_LABEL_LENGTH
label = src.get("label")
label = label.strip() if isinstance(label, str) and label.strip() else None
if isinstance(label, str) and len(label) > MAX_LABEL_LENGTH:
label = None
return {
"status": status,
"user_id": user_id,
"label": label,
"archived": bool(src.get("archived")),
"sort_mode": sort_mode,
"group_by": group_by,
}
def view_matches_task(filters: Mapping[str, Any], task: Mapping[str, Any]) -> bool:
"""Does a task match a view's TASK-SELECTING filters (label + user)?
Used by notification routing ("only notify about view X"). Deliberately
ignores the DISPLAY dimensions: ``status`` (the per-status notify toggles
own that), ``archived`` (archived tasks never notify anyway) and
sort/group. A ``user_id`` of ``current_user`` is a client-side sentinel
that cannot be resolved server-side — treated as "no user filter".
"""
label = filters.get("label")
if label and label not in (task.get("labels") or []):
return False
user_id = filters.get("user_id")
if user_id and user_id != "current_user" and task.get("responsible_user_id") != user_id:
return False
return True
def sanitize_view(raw: Any, *, view_id: str | None = None) -> dict[str, Any] | None:
"""Return a clean view dict, or ``None`` if it has no usable name.
``view_id`` overrides the incoming id (used on save to preserve an existing
id or mint a fresh one); otherwise the raw id is kept when valid.
"""
if not isinstance(raw, Mapping):
return None
name = raw.get("name")
if not isinstance(name, str) or not name.strip():
return None
resolved_id = view_id
if resolved_id is None:
rid = raw.get("id")
resolved_id = rid if isinstance(rid, str) and rid.strip() else uuid4().hex
return {
"id": resolved_id,
"name": name.strip()[:MAX_VIEW_NAME_LENGTH],
"filters": _clean_filters(raw.get("filters")),
}
def list_saved_views(hass: HomeAssistant) -> list[dict[str, Any]]:
"""The sanitised saved views stored on the global entry (empty if none)."""
raw = get_global_options(hass).get(CONF_SAVED_FILTER_VIEWS)
if not isinstance(raw, list):
return []
# Do NOT truncate here: save/delete re-persist this list, so truncating a
# (hand-edited) >MAX list on read would permanently drop the tail on the
# next write. Growth past the cap is prevented on the way IN by upsert_view.
out: list[dict[str, Any]] = []
for item in raw:
clean = sanitize_view(item)
if clean is not None:
out.append(clean)
return out
def upsert_view(views: list[dict[str, Any]], incoming: dict[str, Any]) -> tuple[list[dict[str, Any]], str]:
"""Insert or replace ``incoming`` (matched by id) in ``views``.
Returns the new list and the saved view's id. A new view is rejected past
``MAX_SAVED_VIEWS`` by raising ``ValueError('too_many_views')``; updating an
existing one is always allowed.
"""
result = [dict(v) for v in views]
for i, v in enumerate(result):
if v.get("id") == incoming["id"]:
result[i] = incoming
return result, incoming["id"]
if len(result) >= MAX_SAVED_VIEWS:
raise ValueError("too_many_views")
result.append(incoming)
return result, incoming["id"]
def remove_view(views: list[dict[str, Any]], view_id: str) -> list[dict[str, Any]]:
"""Return ``views`` without the entry whose id is ``view_id``."""
return [dict(v) for v in views if v.get("id") != view_id]