updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
@@ -24,6 +24,8 @@ from ..const import (
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
MAX_ID_LENGTH,
MAX_IMPORT_PAYLOAD_BYTES,
MAX_JSON_IMPORT_PAYLOAD_BYTES,
)
from ..helpers.global_options import get_default_warning_days
from ..helpers.qr_generator import (
@@ -37,6 +39,55 @@ from ..websocket.tasks import _check_nfc_tag_duplicate, _validate_trigger_config
_LOGGER = logging.getLogger(__name__)
def _iso_marker(value: Any) -> str | None:
"""Keep ``value`` only if it parses as an ISO date/datetime, else drop it.
``paused_at`` is a *marker* whose mere presence means "paused"; a garbage
value imported from a hand-edited/foreign backup would otherwise freeze the
object as paused forever (and a malformed ``paused_until`` means auto-resume
never fires). Validate on import so only a real timestamp restores the state.
"""
from datetime import date, datetime
if not isinstance(value, str) or not value.strip():
return None
s = value.strip()
try:
datetime.fromisoformat(s.replace("Z", "+00:00"))
return s
except ValueError:
try:
date.fromisoformat(s)
return s
except ValueError:
return None
def _sanitize_history(history: Any) -> list[dict[str, Any]]:
"""Scrub imported history entries: drop a non-finite/negative ``cost``.
Every live write path range-guards cost, but import copied history verbatim
and ``json.loads``/``yaml.safe_load`` both accept ``NaN``/``Infinity``. Such
a value would poison budget aggregation (a `+inf` fake "budget exceeded"
alert, or `nan` silently disabling all alerts). The completion still counts;
only the bad cost is removed.
"""
import math
if not isinstance(history, list):
return []
out: list[dict[str, Any]] = []
for entry in history:
if not isinstance(entry, dict):
continue
clean = dict(entry)
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)
out.append(clean)
return out
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/templates",
@@ -98,6 +149,8 @@ async def ws_get_templates(
vol.Required("type"): f"{DOMAIN}/export",
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))],
}
)
@websocket_api.require_admin
@@ -107,14 +160,15 @@ async def ws_export_data(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export all maintenance data as JSON or YAML."""
"""Export all (or a selection of) maintenance data as JSON or YAML."""
from ..export import build_export_data, serialize_export
fmt = msg.get("format", "json")
include_history = msg.get("include_history", True)
entry_ids = set(msg["entry_ids"]) if msg.get("entry_ids") else None
# Phase 1: gather data on the event loop (accesses HA APIs)
data = build_export_data(hass, include_history=include_history)
data = build_export_data(hass, include_history=include_history, entry_ids=entry_ids)
# Phase 2: serialize in executor (CPU-bound, no HA API calls)
result = await hass.async_add_executor_job(serialize_export, data, fmt)
@@ -122,7 +176,12 @@ async def ws_export_data(
connection.send_result(msg["id"], {"format": fmt, "data": result})
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/csv/export"})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/csv/export",
vol.Optional("entry_ids"): [vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_export_csv(
@@ -130,28 +189,35 @@ async def ws_export_csv(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export all maintenance data as CSV."""
"""Export all (or a selection of) maintenance data as CSV."""
from ..helpers.csv_handler import export_objects_csv
csv_data = export_objects_csv(hass)
entry_ids = set(msg["entry_ids"]) if msg.get("entry_ids") else None
csv_data = export_objects_csv(hass, entry_ids=entry_ids)
connection.send_result(msg["id"], {"csv": csv_data})
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/objects/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))],
}
)
@websocket_api.async_response
async def ws_export_objects_csv(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export one row per maintenance object as CSV (#67).
"""Export one row per maintenance object as CSV (#67), all or a selection.
Not admin-gated: it exposes only the asset fields the panel already sends
to every user via ``maintenance_supporter/objects`` (no cost/history).
"""
from ..helpers.csv_handler import export_object_records_csv
csv_data = export_object_records_csv(hass)
entry_ids = set(msg["entry_ids"]) if msg.get("entry_ids") else None
csv_data = export_object_records_csv(hass, entry_ids=entry_ids)
connection.send_result(msg["id"], {"csv": csv_data})
@@ -173,7 +239,7 @@ async def ws_import_csv(
csv_content = msg["csv_content"]
# Guard against oversized payloads (max 1MB / 1000 objects)
if len(csv_content) > 1_048_576:
if len(csv_content) > MAX_IMPORT_PAYLOAD_BYTES:
connection.send_error(msg["id"], "too_large", "CSV content exceeds 1MB limit")
return
@@ -274,7 +340,7 @@ async def ws_import_json(
) -> None:
"""Import maintenance objects from JSON or YAML content (from /export)."""
raw = msg["json_content"]
if len(raw) > 10_485_760:
if len(raw) > MAX_JSON_IMPORT_PAYLOAD_BYTES:
connection.send_error(msg["id"], "too_large", "Content exceeds 10MB limit")
return
@@ -340,14 +406,50 @@ async def ws_import_json(
# 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.
"paused_at": obj_data.get("paused_at"),
"paused_until": obj_data.get("paused_until"),
"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"),
"task_ids": [],
}
# Spare parts: regenerate ids (like tasks) and remember the mapping so
# task-side links (consumes_parts / part_ref) can be rewritten below.
# Stock is dynamic Store state — collected here, written after setup.
from uuid import uuid4 as _uuid4
part_id_map: dict[str, str] = {}
import_parts: dict[str, dict[str, Any]] = {}
part_stocks: dict[str, int] = {}
parts_list = obj_entry.get("parts", [])
if isinstance(parts_list, list):
for part_entry in parts_list:
if not isinstance(part_entry, dict) or not (part_entry.get("name") or "").strip():
continue
old_id = str(part_entry.get("id") or "")
new_id = _uuid4().hex
pdata = {k: v for k, v in part_entry.items() if k != "stock"}
pdata["id"] = new_id
# Drop a non-http(s) product_url — the WS write path validates it
# via _clean_url, but import copied it verbatim, so a crafted
# backup could persist a javascript: link (the panel now also
# guards the href, but keep bad data out of storage).
_purl = pdata.get("product_url")
if isinstance(_purl, str) and _purl.strip().lower().startswith(("http://", "https://")):
pdata["product_url"] = _purl.strip() # store trimmed so the render guard matches
else:
pdata.pop("product_url", None)
import_parts[new_id] = pdata
if old_id:
part_id_map[old_id] = new_id
stock = part_entry.get("stock")
if isinstance(stock, int) and stock >= 0:
part_stocks[new_id] = stock
import_tasks: dict[str, dict[str, Any]] = {}
# old task id → new id, so document task-links (task_ids) can be
# remapped onto the freshly generated tasks (mirrors part_id_map).
task_id_map: dict[str, str] = {}
tasks_list = obj_entry.get("tasks", [])
if not isinstance(tasks_list, list):
tasks_list = []
@@ -358,6 +460,9 @@ async def ws_import_json(
if not task_name:
continue
task_id = uuid4().hex
old_task_id = str(task_entry.get("id") or "")
if old_task_id:
task_id_map[old_task_id] = task_id
task_data: dict[str, Any] = {
"id": task_id,
"object_id": obj_id,
@@ -366,9 +471,15 @@ async def ws_import_json(
"enabled": task_entry.get("enabled", True),
"schedule_type": task_entry.get("schedule_type", "time_based"),
"warning_days": task_entry.get("warning_days", get_default_warning_days(hass)),
"history": task_entry.get("history", []),
"history": _sanitize_history(task_entry.get("history", [])),
}
for key in (
# Provenance + lifecycle — mirror the export builder so an
# archived task stays archived and created_at (the next_due
# fallback anchor) survives the round trip.
"created_at",
"archived_at",
"archived_reason",
"interval_days",
"interval_unit",
"due_date",
@@ -400,11 +511,34 @@ async def ws_import_json(
"assignee_pool",
"rotation_strategy",
"reading_unit",
# spare parts (ids remapped below)
"consumes_parts",
"part_ref",
):
val = task_entry.get(key)
if val is not None:
task_data[key] = val
# Remap part links to the regenerated part ids; drop dangling ones.
links = task_data.get("consumes_parts")
if isinstance(links, list):
remapped = [
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
for link in links
if isinstance(link, dict) and link.get("part_id") in part_id_map
]
if remapped:
task_data["consumes_parts"] = remapped
else:
task_data.pop("consumes_parts", None)
elif links is not None:
task_data.pop("consumes_parts", None)
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)
# Sanitize critical fields from import data
iv = task_data.get("interval_days")
if iv is not None and (not isinstance(iv, int) or iv < 1):
@@ -469,6 +603,7 @@ async def ws_import_json(
data={
CONF_OBJECT: import_obj,
CONF_TASKS: import_tasks,
"parts": import_parts,
},
)
except Exception:
@@ -485,6 +620,16 @@ async def ws_import_json(
entry_info["warnings"] = nfc_warnings
created.append(entry_info)
# Restore tracked part stocks into the new entry's Store.
if part_stocks:
new_entry = hass.config_entries.async_get_entry(result["result"].entry_id)
rd_new = getattr(new_entry, "runtime_data", None) if new_entry else None
store_new = getattr(rd_new, "store", None) if rd_new else None
if store_new is not None:
for pid, stock_val in part_stocks.items():
store_new.set_part_stock(pid, stock_val)
await store_new.async_save()
# (roadmap P6) recreate document metadata + web-links for the object
# (blobs travel via the /config backup; a JSON-only import leaves
# file docs dangling, which the storage-hygiene repair issue catches).
@@ -494,7 +639,9 @@ async def ws_import_json(
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
if doc_store is not None:
await doc_store.async_import_documents(obj_id, import_docs)
await doc_store.async_import_documents(
obj_id, import_docs, task_id_map=task_id_map, part_id_map=part_id_map
)
else:
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
@@ -515,7 +662,7 @@ async def ws_import_json(
vol.Optional("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
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.Url(),
vol.Optional("base_url"): vol.All(vol.Url(), vol.Length(max=512)),
}
)
@websocket_api.async_response
@@ -613,7 +760,7 @@ def _cached_qr_svg(url: str, icon: str | None) -> str:
vol.Length(min=1, max=4),
),
vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]),
vol.Optional("base_url"): vol.Url(),
vol.Optional("base_url"): vol.All(vol.Url(), vol.Length(max=512)),
}
)
@websocket_api.async_response