updated apps
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user