217 files
This commit is contained in:
@@ -582,7 +582,7 @@ def _notif_t(key: str, lang: str, **kwargs: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
async def _get_user_notify_services(hass: HomeAssistant, user_id: str) -> list[str]:
|
||||
async def get_user_notify_services(hass: HomeAssistant, user_id: str) -> list[str]:
|
||||
"""Find all notify services for a user via mobile_app config entries.
|
||||
|
||||
Discovery strategy:
|
||||
@@ -940,7 +940,7 @@ class NotificationManager:
|
||||
# Determine target services: user-specific or global
|
||||
target_services = []
|
||||
if responsible_user_id:
|
||||
user_services = await _get_user_notify_services(self.hass, responsible_user_id)
|
||||
user_services = await get_user_notify_services(self.hass, responsible_user_id)
|
||||
if user_services:
|
||||
target_services = user_services
|
||||
_LOGGER.debug(
|
||||
@@ -1271,7 +1271,7 @@ class NotificationManager:
|
||||
|
||||
target_services: list[str] = []
|
||||
if responsible_user_id:
|
||||
user_services = await _get_user_notify_services(self.hass, responsible_user_id)
|
||||
user_services = await get_user_notify_services(self.hass, responsible_user_id)
|
||||
if user_services:
|
||||
target_services = user_services
|
||||
if not target_services and self.notify_service:
|
||||
|
||||
@@ -22,7 +22,7 @@ is passed in here as a plain ``{part_id: stock}`` map.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from urllib.parse import quote_plus
|
||||
@@ -218,21 +218,46 @@ def normalize_part(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return part
|
||||
|
||||
|
||||
def sanitize_consumes_parts(raw: Any, valid_part_ids: set[str] | None = None) -> list[dict[str, Any]]:
|
||||
"""Cap/clean a task's ``consumes_parts`` list ([{part_id, quantity}]).
|
||||
def sanitize_consumes_parts(
|
||||
raw: Any,
|
||||
valid_part_ids: set[str] | None = None,
|
||||
*,
|
||||
foreign_part_ids: Callable[[str], set[str] | None] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Cap/clean a task's ``consumes_parts`` list.
|
||||
|
||||
Unknown part ids are dropped when ``valid_part_ids`` is given; quantity is
|
||||
clamped to 1..MAX_CONSUME_QUANTITY; duplicates collapse (last wins).
|
||||
Shape is ``[{part_id, quantity, entry_id?}]``. A link WITHOUT ``entry_id``
|
||||
consumes a part of the task's own object, which is every link written
|
||||
before 2.45 and stays the default. With ``entry_id`` it consumes a pool
|
||||
owned by another object (#111) — several appliances drawing on one box of
|
||||
filters — and ``foreign_part_ids`` is asked whether that entry really has
|
||||
that part. Without the callback, foreign links are dropped rather than
|
||||
trusted.
|
||||
|
||||
Unknown ids are dropped when the corresponding validator is given; quantity
|
||||
is clamped to 1..MAX_CONSUME_QUANTITY; duplicates collapse (last wins).
|
||||
|
||||
The dedupe key is the (entry_id, part_id) PAIR, not the id alone: part ids
|
||||
are uuid4 in general but the battery fleet mints deterministic ones
|
||||
(``batt_aa``), so two objects genuinely can carry the same id.
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
out: dict[tuple[str | None, str], dict[str, Any]] = {}
|
||||
for item in raw[:MAX_CONSUMES_PER_TASK]:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
part_id = str(item.get("part_id") or "").strip()
|
||||
if not part_id or (valid_part_ids is not None and part_id not in valid_part_ids):
|
||||
if not part_id:
|
||||
continue
|
||||
entry_id = str(item.get("entry_id") or "").strip() or None
|
||||
if entry_id is None:
|
||||
if valid_part_ids is not None and part_id not in valid_part_ids:
|
||||
continue
|
||||
else:
|
||||
known = foreign_part_ids(entry_id) if foreign_part_ids else None
|
||||
if known is None or part_id not in known:
|
||||
continue
|
||||
try:
|
||||
qty = float(item.get("quantity", 1))
|
||||
except (TypeError, ValueError):
|
||||
@@ -241,7 +266,15 @@ def sanitize_consumes_parts(raw: Any, valid_part_ids: set[str] | None = None) ->
|
||||
# invalid input (falls back to 1), matching the old integer clamp.
|
||||
if qty <= 0:
|
||||
qty = 1.0
|
||||
out[part_id] = {"part_id": part_id, "quantity": round_qty(min(qty, MAX_CONSUME_QUANTITY))}
|
||||
link: dict[str, Any] = {
|
||||
"part_id": part_id,
|
||||
"quantity": round_qty(min(qty, MAX_CONSUME_QUANTITY)),
|
||||
}
|
||||
# Only written when the pool lives elsewhere, so a same-object link is
|
||||
# byte-identical to what every earlier version wrote.
|
||||
if entry_id is not None:
|
||||
link["entry_id"] = entry_id
|
||||
out[(entry_id, part_id)] = link
|
||||
return list(out.values())
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ paused) and ``paused_until`` (optional ISO date; the coordinator auto-resumes
|
||||
on the first refresh on/after that day). The resume core is shared between
|
||||
the ``object/resume`` WS command and the coordinator's auto-resume so the two
|
||||
paths cannot drift.
|
||||
|
||||
Because resume and unarchive make the same "fresh cycle" promise, the
|
||||
per-task re-anchor itself lives here too (``reanchor_recurring_task``) and is
|
||||
imported by the object- and task-unarchive handlers — it used to be three
|
||||
hand-copied blocks that had each forgotten ``due_override``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -42,6 +47,54 @@ def pause_due_for_auto_resume(obj: dict[str, Any], today: date) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def clear_cycle_modifiers(task_state: dict[str, Any]) -> None:
|
||||
"""Drop the previous cycle's per-occurrence modifiers from a task dict.
|
||||
|
||||
``last_planned_due`` is the drift-free anchor the next occurrence is
|
||||
computed from; ``due_override`` is a one-shot postpone that
|
||||
``Schedule.next_due`` gives precedence over the cadence whenever it is
|
||||
later than ``last_performed``. Both describe the cycle being abandoned, so
|
||||
both must go when a task is re-anchored.
|
||||
"""
|
||||
task_state.pop("last_planned_due", None)
|
||||
task_state.pop("due_override", None)
|
||||
|
||||
|
||||
def reanchor_recurring_task(
|
||||
task_id: str,
|
||||
*,
|
||||
store: MaintenanceStore | None,
|
||||
today_iso: str,
|
||||
task_data: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Re-anchor ONE recurring task to a fresh cycle starting today.
|
||||
|
||||
The single implementation of the "fresh cycle" promise behind object
|
||||
unarchive, task unarchive and pause/resume. All three previously inlined
|
||||
``last_performed`` + ``last_planned_due`` and all three forgot
|
||||
``due_override`` — so a recurring task postponed to a FUTURE date and then
|
||||
archived/paused came back on that stale postponed date instead of
|
||||
today + interval, which is precisely what a fresh cycle promises not to do.
|
||||
|
||||
Dynamic state lives in the Store when the entry has one; *task_data* is the
|
||||
static ConfigEntry dict. Pass both where available: the modifiers are
|
||||
cleared from BOTH because ``MaintenanceStore.merge_task_data`` lets a
|
||||
static value win when the Store has none, and an IMPORT (websocket/io.py)
|
||||
can still write ``due_override`` into static entry data after migration has
|
||||
already run. ``last_performed`` is only written to *task_data* in the
|
||||
legacy (no-Store) shape.
|
||||
|
||||
Callers own the "is this task recurring?" test and the Store save.
|
||||
"""
|
||||
if task_data is not None:
|
||||
clear_cycle_modifiers(task_data)
|
||||
if store is None:
|
||||
task_data["last_performed"] = today_iso
|
||||
if store is not None:
|
||||
store.set_last_performed(task_id, today_iso)
|
||||
clear_cycle_modifiers(store._ensure_task(task_id))
|
||||
|
||||
|
||||
def build_resumed_entry_data(
|
||||
entry_data: dict[str, Any],
|
||||
store: MaintenanceStore | None,
|
||||
@@ -67,13 +120,7 @@ def build_resumed_entry_data(
|
||||
for tid, td in dict(new_data.get(CONF_TASKS, {})).items():
|
||||
td = dict(td)
|
||||
if td.get("archived_at") is None and is_recurring(td):
|
||||
if store is not None:
|
||||
store.set_last_performed(tid, today_iso)
|
||||
state = store._ensure_task(tid)
|
||||
state.pop("last_planned_due", None)
|
||||
else:
|
||||
td["last_performed"] = today_iso
|
||||
td.pop("last_planned_due", None)
|
||||
reanchor_recurring_task(tid, store=store, today_iso=today_iso, task_data=td)
|
||||
new_tasks[tid] = td
|
||||
new_data[CONF_TASKS] = new_tasks
|
||||
return new_data
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
"""Defensive sanitization for config-flow input.
|
||||
"""Defensive sanitization for the non-WebSocket write paths.
|
||||
|
||||
The WebSocket schemas enforce length and range caps on every str/int field at
|
||||
the boundary. Config-flow forms accept arbitrary lengths because HA's selectors
|
||||
don't enforce them. To keep both paths at parity (and prevent a malicious or
|
||||
buggy programmatic config-flow caller from bloating ConfigEntry.data), every
|
||||
config-flow save handler runs the relevant cap helper below right before
|
||||
persisting.
|
||||
the boundary, so the WS handlers need no sanitiser (they reject rather than
|
||||
truncate). The other two write paths have no such guarantee and DO call the cap
|
||||
helpers below right before persisting:
|
||||
|
||||
* **Config flow** — HA's selectors don't enforce lengths, so a malicious or
|
||||
buggy programmatic flow caller could otherwise bloat ConfigEntry.data. Every
|
||||
save handler caps.
|
||||
* **Services** (``add_task`` / ``update_task``) — their voluptuous schemas
|
||||
mirror the WS caps, but ``websocket/tasks_persist.py``'s
|
||||
``async_create_task_simple`` / ``async_update_task_simple`` are plain Python
|
||||
reachable in-process without going through a schema at all, so they cap too.
|
||||
|
||||
Keeping all three at parity is the point: a value one surface rejects must not
|
||||
be writable through another.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -147,6 +156,13 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
|
||||
if rs not in ROTATION_STRATEGIES:
|
||||
task_data.pop("rotation_strategy", None)
|
||||
|
||||
if task_data.get("required_completion_fields") is not None:
|
||||
from .completion_requirements import sanitize_required_completion_fields
|
||||
|
||||
task_data["required_completion_fields"] = sanitize_required_completion_fields(
|
||||
task_data["required_completion_fields"]
|
||||
)
|
||||
|
||||
seed_rotation_assignee(task_data)
|
||||
|
||||
# v1.3.0: per-task on_complete_action — embedded HA service-call config.
|
||||
|
||||
@@ -42,6 +42,7 @@ from ..const import (
|
||||
CONF_DEFAULT_WARNING_DAYS,
|
||||
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
|
||||
CONF_DISABLED_TEMPLATE_IDS,
|
||||
CONF_INSTALL_ASSIST_SENTENCES,
|
||||
CONF_MAX_NOTIFICATIONS_PER_DAY,
|
||||
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
|
||||
CONF_NOTIFICATION_BUNDLING_ENABLED,
|
||||
@@ -135,6 +136,7 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec(CONF_ACTION_SNOOZE_ENABLED, bool),
|
||||
SettingSpec(CONF_SNOOZE_DURATION_HOURS, int, int_range=(1, 168)),
|
||||
SettingSpec(CONF_WEEKLY_DIGEST_ENABLED, bool),
|
||||
SettingSpec(CONF_INSTALL_ASSIST_SENTENCES, bool),
|
||||
SettingSpec(CONF_WARRANTY_REMINDER_ENABLED, bool),
|
||||
SettingSpec(CONF_WARRANTY_REMINDER_DAYS, int, int_range=(1, 365)),
|
||||
# List of days-before-due (bespoke int-list sanitiser in the WS handler).
|
||||
|
||||
@@ -22,6 +22,9 @@ from ..const import (
|
||||
ROTATION_STRATEGIES,
|
||||
TaskPriority,
|
||||
)
|
||||
from .completion_requirements import (
|
||||
REQUIRABLE_COMPLETION_FIELDS as _REQUIRABLE_COMPLETION_FIELDS,
|
||||
)
|
||||
|
||||
# ─── Enum option sets ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -37,6 +40,10 @@ INTERVAL_ANCHORS: tuple[str, ...] = ("completion", "planned")
|
||||
# task-field consumers import from one module).
|
||||
ROTATION_STRATEGY_VALUES: tuple[str, ...] = ROTATION_STRATEGIES
|
||||
|
||||
# Details a task can demand on completion (re-exported so every task-field
|
||||
# consumer imports its enums from this one module).
|
||||
REQUIRABLE_COMPLETION_FIELDS: tuple[str, ...] = _REQUIRABLE_COMPLETION_FIELDS
|
||||
|
||||
# ─── Numeric bounds (inclusive) ─────────────────────────────────────────────
|
||||
|
||||
WARNING_DAYS_RANGE: tuple[int, int] = (0, 365)
|
||||
|
||||
Reference in New Issue
Block a user