393 files
This commit is contained in:
@@ -26,8 +26,6 @@ from ..const import (
|
||||
CONF_OBJECT_MODEL,
|
||||
CONF_TASKS,
|
||||
DOMAIN,
|
||||
MAX_CHECKLIST_ITEM_LENGTH,
|
||||
MAX_CHECKLIST_ITEMS,
|
||||
MAX_ENTITY_SLUG_LENGTH,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_IMPORT_PAYLOAD_BYTES,
|
||||
@@ -37,6 +35,8 @@ from ..const import (
|
||||
from ..helpers.aggregate import get_store, object_name
|
||||
from ..helpers.dates import normalize_hhmm, parse_iso_date
|
||||
from ..helpers.global_options import get_default_warning_days
|
||||
from ..helpers.history import finite_amount
|
||||
from ..helpers.parts import map_part_links
|
||||
from ..helpers.phases import clamp_phase_cursor, sanitize_phase_defs, sanitize_phase_sequence
|
||||
from ..helpers.qr_generator import (
|
||||
_ACTION_ICON_MAP,
|
||||
@@ -45,7 +45,7 @@ from ..helpers.qr_generator import (
|
||||
generate_qr_svg_data_uri,
|
||||
)
|
||||
from ..websocket.tasks import _check_nfc_tag_duplicate, _validate_trigger_config
|
||||
from . import _get_object_entries, _load_object_entry, _load_object_task
|
||||
from . import ID_FIELD, _get_object_entries, _load_object_entry, _load_object_task, _merge_global_options
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -89,10 +89,24 @@ def _sanitize_history(history: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(history, list):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
dropped = 0
|
||||
for entry in history:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
clean = dict(entry)
|
||||
# The timestamp is what every reader sorts, compares and parses as an
|
||||
# ISO string: a number (epoch) or junk from a hand-edited backup made
|
||||
# the next completion raise TypeError (bug audit 2026-09-27). A
|
||||
# numeric epoch is converted; an entry whose moment cannot be read at
|
||||
# all is dropped — junk text would also sort above every real date
|
||||
# and erase the anchor. An ABSENT timestamp stays as it was (readers
|
||||
# treat it as the empty string).
|
||||
if clean.get("timestamp") is not None:
|
||||
stamp = _history_timestamp(clean["timestamp"])
|
||||
if stamp is None:
|
||||
dropped += 1
|
||||
continue
|
||||
clean["timestamp"] = stamp
|
||||
cost = clean.get("cost")
|
||||
if isinstance(cost, bool) or not isinstance(cost, (int, float)) or not math.isfinite(cost) or cost < 0:
|
||||
clean.pop("cost", None)
|
||||
@@ -102,6 +116,15 @@ def _sanitize_history(history: Any) -> list[dict[str, Any]]:
|
||||
rv = clean.get("reading_value")
|
||||
if rv is not None and (isinstance(rv, bool) or not isinstance(rv, (int, float)) or not math.isfinite(rv)):
|
||||
clean.pop("reading_value", None)
|
||||
# Duration: text or NaN made the object's average-duration sum raise
|
||||
# on every refresh (bug audit 2026-09-26) — numeric text is kept as a
|
||||
# number, anything else dropped.
|
||||
if "duration" in clean:
|
||||
minutes = finite_amount(clean["duration"])
|
||||
if minutes is None:
|
||||
clean.pop("duration", None)
|
||||
else:
|
||||
clean["duration"] = int(minutes) if minutes.is_integer() else minutes
|
||||
if "reading_values" in clean:
|
||||
from ..helpers.reading_slots import history_reading_values
|
||||
|
||||
@@ -111,9 +134,237 @@ def _sanitize_history(history: Any) -> list[dict[str, Any]]:
|
||||
else:
|
||||
clean.pop("reading_values", None)
|
||||
out.append(clean)
|
||||
if dropped:
|
||||
_LOGGER.warning("Import: dropped %d history entr(y/ies) without a readable timestamp", dropped)
|
||||
return out
|
||||
|
||||
|
||||
def _history_timestamp(value: Any) -> str | None:
|
||||
"""An imported history ``timestamp`` as an ISO string, or None.
|
||||
|
||||
ISO datetime / date strings are kept verbatim (live history mixes aware
|
||||
and naive values, and readers compare them as strings); an int / float
|
||||
is taken as a Unix epoch (seconds, or milliseconds when that large) and
|
||||
converted to UTC ISO; anything else is unreadable.
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return text
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
seconds = float(value)
|
||||
if seconds > 1e11: # milliseconds
|
||||
seconds /= 1000
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=UTC).isoformat()
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ── Replace lineage + shared pools across an import (bug audit 2026-09-27) ──
|
||||
#
|
||||
# An import mints NEW entry ids, but objects refer to each other by entry id:
|
||||
# a replacement's ``predecessor_entry_id`` (which also exempts it from the
|
||||
# name rule — the successor usually keeps the old name), the retired object's
|
||||
# ``replaced_by_entry_id``, a ``parent_entry_id`` and a task's part link into
|
||||
# another object's pool (#111). Copied verbatim, every one of them pointed at
|
||||
# the SOURCE instance's objects: a replaced pair restored onto a clean
|
||||
# instance failed with "already_configured" for the active successor, and
|
||||
# every shared-pool link was dropped. Objects are therefore created in
|
||||
# dependency order and these ids remapped through an old→new map.
|
||||
|
||||
_LINEAGE_KEYS = ("predecessor_entry_id", "replaced_by_entry_id", "parent_entry_id")
|
||||
|
||||
|
||||
def _object_dependencies(obj_entry: Any) -> set[str]:
|
||||
"""Old entry ids an import payload object needs created BEFORE it: the
|
||||
object it replaced and the owners of the pools its tasks draw on."""
|
||||
from ..helpers.parts import iter_part_links
|
||||
|
||||
deps: set[str] = set()
|
||||
if not isinstance(obj_entry, dict):
|
||||
return deps
|
||||
obj_data = obj_entry.get("object")
|
||||
pred = obj_data.get("predecessor_entry_id") if isinstance(obj_data, dict) else None
|
||||
if isinstance(pred, str) and pred:
|
||||
deps.add(pred)
|
||||
tasks = obj_entry.get("tasks")
|
||||
for task in tasks if isinstance(tasks, list) else []:
|
||||
if isinstance(task, dict):
|
||||
deps.update(str(link["entry_id"]) for link in iter_part_links(task) if link.get("entry_id"))
|
||||
deps.discard(str(obj_entry.get("entry_id") or ""))
|
||||
return deps
|
||||
|
||||
|
||||
def _import_order(objects: list[Any]) -> list[int]:
|
||||
"""Indexes of ``objects``, each after the payload objects it depends on
|
||||
(:func:`_object_dependencies`); otherwise the payload order. A cycle is
|
||||
broken where it closes (those references then fall back per key)."""
|
||||
by_old: dict[str, int] = {}
|
||||
for idx, obj_entry in enumerate(objects):
|
||||
old = obj_entry.get("entry_id") if isinstance(obj_entry, dict) else None
|
||||
if isinstance(old, str) and old:
|
||||
by_old.setdefault(old, idx)
|
||||
deps = [sorted(by_old[d] for d in _object_dependencies(o) if d in by_old) for o in objects]
|
||||
order: list[int] = []
|
||||
done: set[int] = set()
|
||||
for root in range(len(objects)):
|
||||
if root in done:
|
||||
continue
|
||||
on_stack = {root}
|
||||
stack = [(root, iter(deps[root]))]
|
||||
while stack:
|
||||
node, pending = stack[-1]
|
||||
nxt = next(pending, None)
|
||||
if nxt is None:
|
||||
stack.pop()
|
||||
on_stack.discard(node)
|
||||
done.add(node)
|
||||
order.append(node)
|
||||
elif nxt not in done and nxt not in on_stack:
|
||||
on_stack.add(nxt)
|
||||
stack.append((nxt, iter(deps[nxt])))
|
||||
return order
|
||||
|
||||
|
||||
class _ImportLineage:
|
||||
"""Old→new entry ids (and part ids) of one import run."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, objects: list[Any]) -> None:
|
||||
self._hass = hass
|
||||
self.payload_ids = {
|
||||
str(o["entry_id"]) for o in objects if isinstance(o, dict) and isinstance(o.get("entry_id"), str) and o["entry_id"]
|
||||
}
|
||||
self.entry_ids: dict[str, str] = {}
|
||||
self.part_ids: dict[str, dict[str, str]] = {}
|
||||
self._pending: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
def _live(self, entry_id: str) -> bool:
|
||||
return self._hass.config_entries.async_get_entry(entry_id) is not None
|
||||
|
||||
def apply(self, import_obj: dict[str, Any], obj_data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Set the lineage ids on a new object dict; returns the references to
|
||||
a payload object that is not created yet (resolved by :meth:`finish`).
|
||||
|
||||
An id outside the payload is kept verbatim (a same-instance partial
|
||||
restore keeps it valid; a stale one degrades gracefully at read
|
||||
time). A payload id maps to the object's new entry id; until that
|
||||
exists it falls back to a live object with the old id, else None.
|
||||
"""
|
||||
pending: dict[str, str] = {}
|
||||
for key in _LINEAGE_KEYS:
|
||||
old = obj_data.get(key)
|
||||
if not isinstance(old, str) or not old:
|
||||
import_obj[key] = None
|
||||
continue
|
||||
if old not in self.payload_ids:
|
||||
import_obj[key] = old
|
||||
continue
|
||||
new = self.entry_ids.get(old)
|
||||
if new is None:
|
||||
pending[key] = old
|
||||
new = old if self._live(old) else None
|
||||
import_obj[key] = new
|
||||
return pending
|
||||
|
||||
def created(self, old_entry_id: Any, new_entry_id: str, part_id_map: dict[str, str], pending: dict[str, str]) -> None:
|
||||
if isinstance(old_entry_id, str) and old_entry_id:
|
||||
self.entry_ids[old_entry_id] = new_entry_id
|
||||
self.part_ids[old_entry_id] = dict(part_id_map)
|
||||
if pending:
|
||||
self._pending.append((new_entry_id, pending))
|
||||
|
||||
def link_rewriter(self, own_old_entry_id: str, part_id_map: dict[str, str]) -> Any:
|
||||
"""The rewrite for one object's part links (helpers.parts.map_part_links):
|
||||
own links follow the fresh part ids, a pool link to a payload object
|
||||
follows that object's new ids, a link to a live object (a
|
||||
same-instance import) stays, the rest is dropped rather than left
|
||||
pointing nowhere."""
|
||||
|
||||
def _rewrite(link: dict[str, Any]) -> dict[str, Any] | None:
|
||||
owner = str(link.get("entry_id") or "").strip()
|
||||
part_id = str(link.get("part_id") or "")
|
||||
if not owner or owner == own_old_entry_id:
|
||||
if part_id not in part_id_map:
|
||||
return None
|
||||
return {"part_id": part_id_map[part_id], "quantity": link.get("quantity", 1)}
|
||||
new_owner = self.entry_ids.get(owner)
|
||||
owner_parts = self.part_ids.get(owner) or {}
|
||||
if new_owner is not None and part_id in owner_parts:
|
||||
return {**link, "entry_id": new_owner, "part_id": owner_parts[part_id]}
|
||||
return link if self._live(owner) else None
|
||||
|
||||
return _rewrite
|
||||
|
||||
def finish(self) -> None:
|
||||
"""Point the references to objects created LATER at their new ids."""
|
||||
for new_entry_id, refs in self._pending:
|
||||
entry = self._hass.config_entries.async_get_entry(new_entry_id)
|
||||
if entry is None:
|
||||
continue
|
||||
obj = dict(entry.data.get(CONF_OBJECT) or {})
|
||||
changed = {key: self.entry_ids[old] for key, old in refs.items() if old in self.entry_ids}
|
||||
if not changed:
|
||||
continue
|
||||
obj.update(changed)
|
||||
self._hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_OBJECT: obj})
|
||||
if "parent_entry_id" in changed:
|
||||
# The via_device hierarchy is built when entities are added.
|
||||
self._hass.config_entries.async_schedule_reload(new_entry_id)
|
||||
# A retired object imported without its successor pointer (the CSV
|
||||
# carries only the predecessor) gets it back from the successor.
|
||||
created = set(self.entry_ids.values())
|
||||
for new_entry_id in created:
|
||||
successor = self._hass.config_entries.async_get_entry(new_entry_id)
|
||||
pred_id = (successor.data.get(CONF_OBJECT) or {}).get("predecessor_entry_id") if successor else None
|
||||
if pred_id not in created:
|
||||
continue
|
||||
pred = self._hass.config_entries.async_get_entry(pred_id)
|
||||
pred_obj = dict((pred.data.get(CONF_OBJECT) or {}) if pred else {})
|
||||
if pred is not None and not pred_obj.get("replaced_by_entry_id"):
|
||||
pred_obj["replaced_by_entry_id"] = new_entry_id
|
||||
self._hass.config_entries.async_update_entry(pred, data={**pred.data, CONF_OBJECT: pred_obj})
|
||||
|
||||
|
||||
def _stamp_imported_action_owner(task_data: dict[str, Any], user_id: str | None) -> None:
|
||||
"""A completion action restored from a file runs as the IMPORTING admin.
|
||||
|
||||
The file's ``configured_by`` is never trusted — it could name any user,
|
||||
an admin included (json/import is admin-only, but the file may come from
|
||||
anywhere). Stamping the admin who imported it keeps the SEC-2 model
|
||||
("every action runs as a real user") instead of silently falling back to
|
||||
system rights, and records who authorised it (bug audit 2026-09-27;
|
||||
docs/CONFIGURATION.md "Who the action runs as").
|
||||
"""
|
||||
from ..helpers.sanitize import ACTION_OWNER_KEY
|
||||
|
||||
action = task_data.get("on_complete_action")
|
||||
if not isinstance(action, dict):
|
||||
return
|
||||
action = {k: v for k, v in action.items() if k != ACTION_OWNER_KEY}
|
||||
if user_id:
|
||||
action[ACTION_OWNER_KEY] = user_id
|
||||
task_data["on_complete_action"] = action
|
||||
|
||||
|
||||
def _future_last_performed(value: Any) -> bool:
|
||||
"""A last-performed date after today — dropped on import with a warning
|
||||
(bug audit 2026-09-27: a year-9999 anchor overflowed the schedule math
|
||||
inside every refresh and kept the object in setup-retry)."""
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
parsed = parse_iso_date(value) if isinstance(value, str) else None
|
||||
return parsed is not None and parsed > dt_util.now().date()
|
||||
|
||||
|
||||
def _remap_document_refs(
|
||||
import_tasks: dict[str, dict[str, Any]],
|
||||
import_parts: dict[str, dict[str, Any]],
|
||||
@@ -252,36 +503,51 @@ async def ws_get_templates(
|
||||
|
||||
Every template is returned with a ``disabled`` flag (v2.21 gallery
|
||||
curation): the pickers hide disabled ones client-side, while the Settings
|
||||
section needs the full list to render the toggles.
|
||||
section needs the full list to render the toggles. v2.93 adds the home
|
||||
``profile`` (dwelling, climate, country — all derived locally) and per
|
||||
template whether the gallery recommends it and why; 2.94 ``set_up`` —
|
||||
an active object already stands for it (helpers.template_usage), so it
|
||||
is not recommended again.
|
||||
"""
|
||||
from ..helpers.home_profile import async_home_profile
|
||||
from ..helpers.i18n import normalize_language, normalize_language_code
|
||||
from ..helpers.template_usage import templates_in_use
|
||||
from ..templates import (
|
||||
TEMPLATE_CATEGORIES,
|
||||
TEMPLATES,
|
||||
get_disabled_template_ids,
|
||||
localize_template_text,
|
||||
recommend_template,
|
||||
task_interval,
|
||||
template_tasks,
|
||||
)
|
||||
|
||||
disabled = get_disabled_template_ids(hass)
|
||||
profile = await async_home_profile(hass)
|
||||
in_use = templates_in_use(hass)
|
||||
lang = normalize_language_code(msg.get("language")) if msg.get("language") else normalize_language(hass)
|
||||
|
||||
result = {
|
||||
"categories": {cat_id: {k: v for k, v in cat.items()} for cat_id, cat in TEMPLATE_CATEGORIES.items()},
|
||||
"profile": profile.as_dict(),
|
||||
"templates": [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": localize_template_text(t.name, lang),
|
||||
"category": t.category,
|
||||
"disabled": t.id in disabled,
|
||||
**recommend_template(t, profile, set_up=t.id in in_use),
|
||||
"tasks": [
|
||||
{
|
||||
"name": localize_template_text(tt.name, lang),
|
||||
"type": tt.type,
|
||||
"schedule_type": tt.schedule_type,
|
||||
"interval_days": tt.interval_days,
|
||||
"interval_days": task_interval(tt, profile.country, profile.region),
|
||||
"warning_days": tt.warning_days,
|
||||
}
|
||||
for tt in t.tasks
|
||||
# What creating it here makes: winter-only tasks left
|
||||
# out without a cold season, the country's cycle.
|
||||
for tt in template_tasks(t, has_winter=profile.has_winter, country=profile.country, region=profile.region)
|
||||
],
|
||||
}
|
||||
for t in TEMPLATES
|
||||
@@ -296,7 +562,7 @@ async def ws_get_templates(
|
||||
vol.Optional("format", default="json"): vol.In(["json", "yaml"]),
|
||||
vol.Optional("include_history", default=True): bool,
|
||||
# Selective export: restrict to these object entry_ids (omit = all).
|
||||
vol.Optional("entry_ids"): [vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
vol.Optional("entry_ids"): [ID_FIELD],
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@@ -325,7 +591,7 @@ async def ws_export_data(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/csv/export",
|
||||
vol.Optional("entry_ids"): [vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
vol.Optional("entry_ids"): [ID_FIELD],
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@@ -346,7 +612,7 @@ async def ws_export_csv(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/objects/csv",
|
||||
vol.Optional("entry_ids"): [vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
vol.Optional("entry_ids"): [ID_FIELD],
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -400,9 +666,15 @@ async def ws_import_csv(
|
||||
|
||||
created = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for idx, obj_data in enumerate(objects):
|
||||
# Rows are grouped by the source object (object_entry_id column), so a
|
||||
# replaced pair of the same name stays two objects; the pair's lineage
|
||||
# is remapped like the JSON import's (bug audit 2026-09-27).
|
||||
lineage = _ImportLineage(hass, objects)
|
||||
for idx in _import_order(objects):
|
||||
obj_data = objects[idx]
|
||||
pending = lineage.apply(obj_data["object"], obj_data["object"])
|
||||
# Check for NFC tag duplicates in CSV-imported tasks
|
||||
nfc_warnings: list[str] = []
|
||||
nfc_warnings: list[str] = list(obj_data.get("warnings") or [])
|
||||
for t_data in obj_data.get("tasks", {}).values():
|
||||
nfc_val = t_data.get("nfc_tag_id")
|
||||
if nfc_val:
|
||||
@@ -425,6 +697,7 @@ async def ws_import_csv(
|
||||
errors.append({"name": obj_name, "reason": "unexpected error"})
|
||||
continue
|
||||
if result["type"] == "create_entry":
|
||||
lineage.created(obj_data.get("entry_id"), result["result"].entry_id, {}, pending)
|
||||
entry_info: dict[str, Any] = {
|
||||
"entry_id": result["result"].entry_id,
|
||||
"name": obj_data["object"].get("name", ""),
|
||||
@@ -436,6 +709,7 @@ async def ws_import_csv(
|
||||
else:
|
||||
obj_name = obj_data.get("object", {}).get("name", f"row {idx + 1}")
|
||||
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
|
||||
lineage.finish()
|
||||
|
||||
resp: dict[str, Any] = {
|
||||
"imported": created,
|
||||
@@ -512,7 +786,6 @@ def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str
|
||||
"""
|
||||
from ..const import (
|
||||
CONF_GROUPS,
|
||||
CONF_NOTIFY_SERVICE,
|
||||
CONF_SAVED_FILTER_VIEWS,
|
||||
CONF_VACATION_BUFFER_DAYS,
|
||||
CONF_VACATION_ENABLED,
|
||||
@@ -521,21 +794,35 @@ def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str
|
||||
CONF_VACATION_START,
|
||||
MAX_GROUP_TASK_REFS,
|
||||
MAX_NAME_LENGTH,
|
||||
MAX_SAVED_VIEWS,
|
||||
)
|
||||
from ..export import _NON_PORTABLE_SETTINGS
|
||||
from ..helpers.global_options import get_global_entry
|
||||
from ..helpers.saved_views import MAX_SAVED_VIEWS, sanitize_view
|
||||
from ..helpers.saved_views import sanitize_view
|
||||
from ..helpers.settings_registry import ALLOWED_SETTING_KEYS
|
||||
from .dashboard import sanitize_settings_input
|
||||
from .dashboard import sanitize_settings_input, settings_error_field
|
||||
|
||||
entry = get_global_entry(hass)
|
||||
if entry is None or not isinstance(raw, dict):
|
||||
return []
|
||||
|
||||
scalars = {k: v for k, v in raw.items() if k in ALLOWED_SETTING_KEYS and k not in _NON_PORTABLE_SETTINGS}
|
||||
filtered, notify_error = sanitize_settings_input(scalars)
|
||||
if notify_error:
|
||||
filtered.pop(CONF_NOTIFY_SERVICE, None)
|
||||
# The sanitizer stops at the FIRST invalid field and hands back what it
|
||||
# had so far. The import used to drop only notify_service after any error
|
||||
# — an invalid search template (javascript: included) or shopping list was
|
||||
# saved as-is and the later checks never ran (found 2026-09-26). Drop the
|
||||
# field each error names and validate the rest again.
|
||||
filtered: dict[str, Any] = {}
|
||||
for _ in range(len(scalars) + 1):
|
||||
filtered, error = sanitize_settings_input(scalars)
|
||||
if error is None:
|
||||
break
|
||||
bad = settings_error_field(error)
|
||||
_LOGGER.warning("Settings import: %s dropped (%s)", bad, error)
|
||||
if bad not in scalars:
|
||||
filtered = {}
|
||||
break
|
||||
scalars = {k: v for k, v in scalars.items() if k != bad}
|
||||
|
||||
groups_in = raw.get(CONF_GROUPS)
|
||||
if isinstance(groups_in, dict):
|
||||
@@ -568,10 +855,25 @@ def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str
|
||||
|
||||
if isinstance(raw.get(CONF_VACATION_ENABLED), bool):
|
||||
filtered[CONF_VACATION_ENABLED] = raw[CONF_VACATION_ENABLED]
|
||||
# Same bound as vacation/update: a date more than MAX_INTERVAL_DAYS out
|
||||
# is no vacation — 9999-12-31 overflowed the calendar inside every
|
||||
# object's refresh (bug audit 2026-09-27). Dropped with a warning.
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ..const import MAX_INTERVAL_DAYS
|
||||
|
||||
latest_vacation_day = dt_util.now().date() + timedelta(days=MAX_INTERVAL_DAYS)
|
||||
for key in (CONF_VACATION_START, CONF_VACATION_END):
|
||||
val = raw.get(key)
|
||||
if isinstance(val, str) and parse_iso_date(val) is not None:
|
||||
filtered[key] = val
|
||||
parsed_day = parse_iso_date(val) if isinstance(val, str) else None
|
||||
if parsed_day is None:
|
||||
continue
|
||||
if parsed_day > latest_vacation_day:
|
||||
_LOGGER.warning("Settings import: %s %s is more than %d days away — dropped", key, val, MAX_INTERVAL_DAYS)
|
||||
continue
|
||||
filtered[key] = val
|
||||
if isinstance(raw.get(CONF_VACATION_BUFFER_DAYS), int) and not isinstance(raw.get(CONF_VACATION_BUFFER_DAYS), bool):
|
||||
filtered[CONF_VACATION_BUFFER_DAYS] = raw[CONF_VACATION_BUFFER_DAYS]
|
||||
exempt = raw.get(CONF_VACATION_EXEMPT_TASK_IDS)
|
||||
@@ -581,9 +883,7 @@ def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str
|
||||
|
||||
if not filtered:
|
||||
return []
|
||||
merged = dict(entry.options or entry.data)
|
||||
merged.update(filtered)
|
||||
hass.config_entries.async_update_entry(entry, options=merged)
|
||||
_merge_global_options(hass, entry, filtered)
|
||||
_LOGGER.info("Settings import applied %d key(s)", len(filtered))
|
||||
return sorted(filtered)
|
||||
|
||||
@@ -602,6 +902,8 @@ async def ws_import_json(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Import maintenance objects from JSON or YAML content (from /export)."""
|
||||
from ..templates import KNOWN_TEMPLATE_IDS
|
||||
|
||||
raw = msg["json_content"]
|
||||
if len(raw) > MAX_JSON_IMPORT_PAYLOAD_BYTES:
|
||||
connection.send_error(msg["id"], "too_large", "Content exceeds 10MB limit")
|
||||
@@ -639,7 +941,10 @@ async def ws_import_json(
|
||||
|
||||
created = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for idx, obj_entry in enumerate(objects):
|
||||
importing_user = connection.user.id if connection.user else None
|
||||
lineage = _ImportLineage(hass, objects)
|
||||
for idx in _import_order(objects):
|
||||
obj_entry = objects[idx]
|
||||
# Guard against malformed-but-schema-valid input (the schema only checks
|
||||
# json_content is a str): a non-dict entry / non-dict object would raise
|
||||
# AttributeError and escape the per-object try/except below.
|
||||
@@ -669,17 +974,14 @@ async def ws_import_json(
|
||||
# cap_object_fields and the frontend only renders http(s) doc URLs.
|
||||
"documentation_url": obj_data.get("documentation_url"),
|
||||
"notes": obj_data.get("notes"),
|
||||
# 2.19: device link / parent hierarchy — same-instance restores
|
||||
# keep them valid; stale ids degrade gracefully at read time.
|
||||
# 2.19: device link. Same-instance restores keep it valid; a
|
||||
# stale id degrades gracefully at read time. The parent and the
|
||||
# replace lineage are remapped just below (_ImportLineage).
|
||||
"ha_device_id": obj_data.get("ha_device_id"),
|
||||
"parent_entry_id": obj_data.get("parent_entry_id"),
|
||||
# 2.20: seasonal pause round-trips (a paused pool restored in
|
||||
# winter stays paused); replace-flow lineage ids are the same
|
||||
# instance-specific story as parent_entry_id above.
|
||||
# winter stays paused).
|
||||
"paused_at": _iso_marker(obj_data.get("paused_at")),
|
||||
"paused_until": _iso_marker(obj_data.get("paused_until")),
|
||||
"predecessor_entry_id": obj_data.get("predecessor_entry_id"),
|
||||
"replaced_by_entry_id": obj_data.get("replaced_by_entry_id"),
|
||||
# Object-level archive marker — same presence-means-archived
|
||||
# semantics as paused_at, so it gets the same ISO validation. Its
|
||||
# tasks carry their own archived_* pair (mirrored below).
|
||||
@@ -690,6 +992,12 @@ async def ws_import_json(
|
||||
"next_task_ref": _ref_or_none(obj_data.get("next_task_ref")),
|
||||
"task_ids": [],
|
||||
}
|
||||
# 2.94: the source template, when the backup names one we know.
|
||||
if obj_data.get("template_id") in KNOWN_TEMPLATE_IDS:
|
||||
import_obj["template_id"] = obj_data["template_id"]
|
||||
# parent / predecessor / replaced_by → the NEW entry ids.
|
||||
lineage_pending = lineage.apply(import_obj, obj_data)
|
||||
own_old_entry_id = str(obj_entry.get("entry_id") or "")
|
||||
|
||||
# Battery fleet identity (object flag + exclude/include lists + the
|
||||
# self-charging opt-in). The fleet is ONE object by invariant
|
||||
@@ -858,73 +1166,38 @@ async def ws_import_json(
|
||||
):
|
||||
link["part_id"] = part_id_map[link["part_id"]]
|
||||
|
||||
# Remap part links to the regenerated part ids; drop dangling ones.
|
||||
links = task_data.get("consumes_parts")
|
||||
if isinstance(links, list):
|
||||
remapped = []
|
||||
for link in links:
|
||||
if not isinstance(link, dict):
|
||||
continue
|
||||
foreign = str(link.get("entry_id") or "").strip()
|
||||
if foreign:
|
||||
# A link to another object's pool (#111). Import mints
|
||||
# new entry ids, so the reference only means anything
|
||||
# if that object is present in THIS instance — keep it
|
||||
# then, drop it otherwise rather than restore a link
|
||||
# that points nowhere.
|
||||
if hass.config_entries.async_get_entry(foreign) is not None:
|
||||
remapped.append(dict(link))
|
||||
elif link.get("part_id") in part_id_map:
|
||||
remapped.append(
|
||||
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
|
||||
)
|
||||
if remapped:
|
||||
task_data["consumes_parts"] = remapped
|
||||
else:
|
||||
task_data.pop("consumes_parts", None)
|
||||
elif links is not None:
|
||||
# Task phases (#139): sanitize like the live WS write and clamp the
|
||||
# cursor to the imported sequence. The cursor rides entry.data
|
||||
# until the fresh entry's first setup migrates it into the Store
|
||||
# (dynamic field), so a restore resumes mid-cycle.
|
||||
raw_defs = task_entry.get("phases")
|
||||
raw_seq = task_entry.get("phase_sequence")
|
||||
if isinstance(raw_defs, dict) and isinstance(raw_seq, list):
|
||||
defs = sanitize_phase_defs(raw_defs)
|
||||
seq = sanitize_phase_sequence(raw_seq, defs)
|
||||
if defs and seq:
|
||||
task_data["phases"] = defs
|
||||
task_data["phase_sequence"] = seq
|
||||
task_data["phase_cursor"] = clamp_phase_cursor(task_entry.get("phase_cursor"), len(seq))
|
||||
|
||||
# Part links — task level AND per phase (helpers.parts.
|
||||
# map_part_links, one rule for both): own links follow the
|
||||
# regenerated part ids; a pool of another object (#111) follows
|
||||
# that object's new ids when it is part of this import, stays
|
||||
# when the object lives in THIS instance, and is dropped rather
|
||||
# than restored pointing nowhere.
|
||||
if task_data.get("consumes_parts") is not None and not isinstance(task_data["consumes_parts"], list):
|
||||
task_data.pop("consumes_parts", None)
|
||||
task_data, _links_changed = map_part_links(task_data, lineage.link_rewriter(own_old_entry_id, part_id_map))
|
||||
ref = task_data.get("part_ref")
|
||||
if isinstance(ref, dict) and ref.get("part_id") in part_id_map:
|
||||
task_data["part_ref"] = {"part_id": part_id_map[ref["part_id"]]}
|
||||
elif ref is not None:
|
||||
task_data.pop("part_ref", None)
|
||||
|
||||
# Task phases (#139): sanitize like the live WS write, remap each
|
||||
# phase's part links to the regenerated ids (same rules as the
|
||||
# task-level links above), and clamp the cursor to the imported
|
||||
# sequence. The cursor rides entry.data until the fresh entry's
|
||||
# first setup migrates it into the Store (dynamic field), so a
|
||||
# restore resumes mid-cycle.
|
||||
raw_defs = task_entry.get("phases")
|
||||
raw_seq = task_entry.get("phase_sequence")
|
||||
if isinstance(raw_defs, dict) and isinstance(raw_seq, list):
|
||||
defs = sanitize_phase_defs(raw_defs)
|
||||
for pdef in defs.values():
|
||||
plinks = pdef.get("consumes_parts")
|
||||
if not isinstance(plinks, list):
|
||||
continue
|
||||
kept = []
|
||||
for link in plinks:
|
||||
if not isinstance(link, dict):
|
||||
continue
|
||||
foreign = str(link.get("entry_id") or "").strip()
|
||||
if foreign:
|
||||
if hass.config_entries.async_get_entry(foreign) is not None:
|
||||
kept.append(dict(link))
|
||||
elif link.get("part_id") in part_id_map:
|
||||
kept.append(
|
||||
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
|
||||
)
|
||||
if kept:
|
||||
pdef["consumes_parts"] = kept
|
||||
else:
|
||||
pdef.pop("consumes_parts", None)
|
||||
seq = sanitize_phase_sequence(raw_seq, defs)
|
||||
if defs and seq:
|
||||
task_data["phases"] = defs
|
||||
task_data["phase_sequence"] = seq
|
||||
task_data["phase_cursor"] = clamp_phase_cursor(task_entry.get("phase_cursor"), len(seq))
|
||||
# A completion action runs as the importing admin — never as the
|
||||
# user a file names (bug audit 2026-09-27, SEC-2).
|
||||
_stamp_imported_action_owner(task_data, importing_user)
|
||||
|
||||
# Sanitize critical fields from import data
|
||||
iv = task_data.get("interval_days")
|
||||
@@ -933,6 +1206,9 @@ async def ws_import_json(
|
||||
lp = task_data.get("last_performed")
|
||||
if lp is not None and parse_iso_date(lp) is None:
|
||||
task_data.pop("last_performed", None)
|
||||
elif _future_last_performed(lp):
|
||||
task_data.pop("last_performed", None)
|
||||
task_warnings.append(f"{task_name}: last performed date {lp} is in the future — dropped")
|
||||
wd = task_data.get("warning_days")
|
||||
if not isinstance(wd, int) or wd < 0 or wd > 365:
|
||||
task_data["warning_days"] = get_default_warning_days(hass)
|
||||
@@ -941,39 +1217,12 @@ async def ws_import_json(
|
||||
from ..helpers.sanitize import seed_rotation_assignee
|
||||
|
||||
seed_rotation_assignee(task_data)
|
||||
# Sanitize checklist: only keep string items within length budget,
|
||||
# cap total items. Drops malformed entries silently rather than
|
||||
# rejecting the whole import — same forgiving model as the other
|
||||
# fields above.
|
||||
cl = task_data.get("checklist")
|
||||
if cl is not None:
|
||||
if not isinstance(cl, list):
|
||||
task_data.pop("checklist", None)
|
||||
else:
|
||||
cleaned = [item.strip() for item in cl if isinstance(item, str) and len(item) <= MAX_CHECKLIST_ITEM_LENGTH]
|
||||
cleaned = [c for c in cleaned if c]
|
||||
task_data["checklist"] = cleaned[:MAX_CHECKLIST_ITEMS]
|
||||
|
||||
# #161 phase 2: reading slots — same shape rules as the WS write.
|
||||
if task_data.get("readings") is not None:
|
||||
from ..helpers.reading_slots import sanitize_reading_slots
|
||||
|
||||
slots = sanitize_reading_slots(task_data["readings"])
|
||||
if slots:
|
||||
task_data["readings"] = slots
|
||||
else:
|
||||
task_data.pop("readings", None)
|
||||
|
||||
# D#183: mirror targets — todo.* ids only, deduped, capped; an
|
||||
# empty result drops the key (same rules as the WS write paths).
|
||||
if task_data.get("mirror_todo_entities") is not None:
|
||||
from ..helpers.sanitize import sanitize_mirror_todo_entities
|
||||
|
||||
mirrors = sanitize_mirror_todo_entities(task_data["mirror_todo_entities"])
|
||||
if mirrors:
|
||||
task_data["mirror_todo_entities"] = mirrors
|
||||
else:
|
||||
task_data.pop("mirror_todo_entities", None)
|
||||
# checklist (strip + truncate + cap), reading slots and to-do
|
||||
# mirror targets are NOT re-sanitized here: the config flow's
|
||||
# websocket step runs cap_task_fields on every imported task —
|
||||
# the same code, and nothing in between reads them (DRY audit
|
||||
# 2026-09-26 B). The rotation seed above stays: it looks at the
|
||||
# raw strategy, which cap_task_fields would drop first.
|
||||
|
||||
# #185: notify_icon — same shape rule as the WS write paths; a
|
||||
# malformed or empty value drops the override (type default).
|
||||
@@ -1100,6 +1349,7 @@ async def ws_import_json(
|
||||
await _drop_imported_documents(doc_store, obj_id)
|
||||
continue
|
||||
if result["type"] == "create_entry":
|
||||
lineage.created(obj_entry.get("entry_id"), result["result"].entry_id, part_id_map, lineage_pending)
|
||||
entry_info: dict[str, Any] = {
|
||||
"entry_id": result["result"].entry_id,
|
||||
"name": obj_name,
|
||||
@@ -1130,6 +1380,7 @@ async def ws_import_json(
|
||||
else:
|
||||
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
|
||||
await _drop_imported_documents(doc_store, obj_id)
|
||||
lineage.finish()
|
||||
|
||||
resp: dict[str, Any] = {
|
||||
"imported": created,
|
||||
@@ -1146,8 +1397,8 @@ async def ws_import_json(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/qr/generate",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Optional("task_id"): ID_FIELD,
|
||||
vol.Optional("action", default="view"): vol.In(["view", "complete", "quick_complete"]),
|
||||
vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]),
|
||||
vol.Optional("base_url"): vol.All(vol.Url(), vol.Length(max=512)),
|
||||
@@ -1170,9 +1421,10 @@ async def ws_generate_qr(
|
||||
entry, _rd, task = ctx
|
||||
task_name = task.get("name", "")
|
||||
else:
|
||||
entry = _load_object_entry(hass, connection, msg)
|
||||
if entry is None:
|
||||
obj_entry = _load_object_entry(hass, connection, msg)
|
||||
if obj_entry is None:
|
||||
return
|
||||
entry = obj_entry
|
||||
|
||||
obj_data = entry.data.get(CONF_OBJECT, {})
|
||||
|
||||
@@ -1235,11 +1487,11 @@ def _cached_qr_svg(url: str, icon: str | None) -> str:
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/qr/batch_generate",
|
||||
vol.Optional("entry_ids"): vol.All(
|
||||
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
[ID_FIELD],
|
||||
vol.Length(max=1000),
|
||||
),
|
||||
vol.Optional("task_ids"): vol.All(
|
||||
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
[ID_FIELD],
|
||||
vol.Length(max=2000),
|
||||
),
|
||||
vol.Required("actions"): vol.All(
|
||||
|
||||
Reference in New Issue
Block a user