Files
Home-Assistant/custom_components/maintenance_supporter/helpers/csv_handler.py
T
2026-07-08 10:43:39 -04:00

327 lines
13 KiB
Python

"""CSV import/export for maintenance objects and tasks."""
from __future__ import annotations
import csv
import io
import logging
import re
from typing import Any
from uuid import uuid4
from homeassistant.core import HomeAssistant
from ..const import (
CONF_OBJECT,
CONF_TASKS,
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
)
from .dates import INTERVAL_UNITS
from .global_options import get_default_warning_days
from .schedule import read_legacy_fields
_LOGGER = logging.getLogger(__name__)
# CSV column order
_COLUMNS = [
"object_name",
"object_manufacturer",
"object_model",
"object_serial_number",
"object_area_id",
"object_installation_date",
"object_warranty_expiry",
"task_name",
"task_type",
"enabled",
"schedule_type",
"interval_days",
"interval_unit",
"due_date",
"interval_anchor",
"schedule_time",
"reading_unit",
"warning_days",
"last_performed",
"notes",
"documentation_url",
"custom_icon",
"nfc_tag_id",
"responsible_user_id",
"trigger_type",
"status",
"times_performed",
"total_cost",
# Checklist exported as a single cell with steps separated by literal "\n".
# The csv module handles the embedded newlines via RFC 4180 field quoting.
"checklist",
]
def _csv_safe(val: str) -> str:
"""Prefix cells that start with formula-triggering characters to mitigate CSV injection."""
if val and val[0] in ("=", "+", "-", "@"):
return "\t" + val
return val
def export_objects_csv(hass: HomeAssistant) -> str:
"""Export all maintenance objects and tasks as CSV.
Each row represents one task, with the parent object info repeated.
"""
entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID]
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=_COLUMNS, extrasaction="ignore")
writer.writeheader()
for entry in entries:
obj_data = entry.data.get(CONF_OBJECT, {})
# Merge static + Store dynamic data
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
static_tasks = entry.data.get(CONF_TASKS, {})
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
rd = getattr(entry, "runtime_data", None)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
ct_tasks = (coord_data or {}).get(CONF_TASKS, {})
for tid, tdata in tasks_data.items():
ct = ct_tasks.get(tid, {})
sched = read_legacy_fields(tdata)
writer.writerow(
{
"object_name": _csv_safe(obj_data.get("name", "")),
"object_manufacturer": _csv_safe(obj_data.get("manufacturer", "")),
"object_model": _csv_safe(obj_data.get("model", "")),
"object_serial_number": _csv_safe(obj_data.get("serial_number", "")),
"object_area_id": obj_data.get("area_id", ""),
"object_installation_date": obj_data.get("installation_date", ""),
"object_warranty_expiry": obj_data.get("warranty_expiry", ""),
"task_name": _csv_safe(tdata.get("name", "")),
"task_type": tdata.get("type", "custom"),
"enabled": tdata.get("enabled", True),
"schedule_type": sched["schedule_type"],
"interval_days": sched["interval_days"] if sched["interval_days"] is not None else "",
"interval_unit": sched["interval_unit"],
"due_date": sched["due_date"] or "",
"interval_anchor": sched["interval_anchor"],
"schedule_time": tdata.get("schedule_time", ""),
"reading_unit": tdata.get("reading_unit", ""),
"warning_days": tdata.get("warning_days", DEFAULT_WARNING_DAYS),
"last_performed": tdata.get("last_performed", ""),
"notes": _csv_safe(tdata.get("notes", "")),
"documentation_url": _csv_safe(tdata.get("documentation_url", "")),
"custom_icon": _csv_safe(tdata.get("custom_icon", "")),
"nfc_tag_id": _csv_safe(tdata.get("nfc_tag_id", "")),
"responsible_user_id": _csv_safe(tdata.get("responsible_user_id", "")),
"trigger_type": (tdata.get("trigger_config") or {}).get("type", ""),
"status": ct.get("_status", "ok"),
"times_performed": ct.get("_times_performed", 0),
"total_cost": ct.get("_total_cost", 0.0),
# Each item is _csv_safe()-prefixed individually so a step
# starting with "=" can't trigger a formula in Excel after
# the cell is unpacked.
"checklist": "\n".join(_csv_safe(item) for item in (tdata.get("checklist") or []) if item),
}
)
return output.getvalue()
# (#67) Per-object CSV columns — one row per maintenance object (asset record).
_OBJECT_RECORD_COLUMNS = [
"object_name",
"object_manufacturer",
"object_model",
"object_serial_number",
"object_area_id",
"object_installation_date",
"object_warranty_expiry",
"object_documentation_url",
"object_notes",
"task_count",
]
def export_object_records_csv(hass: HomeAssistant) -> str:
"""Export one row per maintenance object (the objects-table download, #67).
Unlike ``export_objects_csv`` (one row per task, object fields repeated),
this emits exactly one row per object — including objects that have no
tasks, which the per-task export skips entirely — and carries the full
asset field set used by the objects table.
"""
entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID]
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=_OBJECT_RECORD_COLUMNS, extrasaction="ignore")
writer.writeheader()
for entry in entries:
obj_data = entry.data.get(CONF_OBJECT, {})
static_tasks = entry.data.get(CONF_TASKS, {})
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
writer.writerow(
{
"object_name": _csv_safe(obj_data.get("name", "")),
"object_manufacturer": _csv_safe(obj_data.get("manufacturer") or ""),
"object_model": _csv_safe(obj_data.get("model") or ""),
"object_serial_number": _csv_safe(obj_data.get("serial_number") or ""),
"object_area_id": obj_data.get("area_id") or "",
"object_installation_date": obj_data.get("installation_date") or "",
"object_warranty_expiry": obj_data.get("warranty_expiry") or "",
"object_documentation_url": _csv_safe(obj_data.get("documentation_url") or ""),
"object_notes": _csv_safe(obj_data.get("notes") or ""),
"task_count": len(tasks_data),
}
)
return output.getvalue()
def import_objects_csv(
csv_content: str,
hass: HomeAssistant | None = None,
) -> list[dict[str, Any]]:
"""Parse CSV content into a list of object dicts ready for creation.
When *hass* is supplied, missing per-row ``warning_days`` columns fall back
to the integration-wide default from the global config entry. Without
*hass* (e.g. in unit tests that exercise the parser in isolation), the
bare constant ``7`` is used.
Returns a list of objects, each with 'object' and 'tasks' dicts
matching the format expected by the config flow.
"""
default_warning_days = get_default_warning_days(hass) if hass is not None else 7
reader = csv.DictReader(io.StringIO(csv_content))
# Group rows by object name
objects_map: dict[str, dict[str, Any]] = {}
for row in reader:
obj_name = (row.get("object_name") or "").strip()
if not obj_name:
continue
if obj_name not in objects_map:
objects_map[obj_name] = {
"object": {
"id": uuid4().hex,
"name": obj_name,
"manufacturer": (row.get("object_manufacturer") or "").strip() or None,
"model": (row.get("object_model") or "").strip() or None,
"serial_number": (row.get("object_serial_number") or "").strip() or None,
"area_id": (row.get("object_area_id") or "").strip() or None,
"installation_date": (row.get("object_installation_date") or "").strip() or None,
"warranty_expiry": (row.get("object_warranty_expiry") or "").strip() or None,
"task_ids": [],
},
"tasks": {},
}
task_name = (row.get("task_name") or "").strip()
if not task_name:
continue
task_id = uuid4().hex
task_data: dict[str, Any] = {
"id": task_id,
"object_id": objects_map[obj_name]["object"]["id"],
"name": task_name,
"type": (row.get("task_type") or "custom").strip(),
"enabled": True,
"schedule_type": (row.get("schedule_type") or "time_based").strip(),
"warning_days": _safe_int(row.get("warning_days"), default_warning_days),
"history": [],
}
interval = row.get("interval_days", "").strip()
if interval:
task_data["interval_days"] = _safe_int(interval, None)
interval_unit = (row.get("interval_unit") or "").strip().lower()
if interval_unit in INTERVAL_UNITS:
task_data["interval_unit"] = interval_unit
due_date = (row.get("due_date") or "").strip()
if due_date and re.fullmatch(r"\d{4}-\d{2}-\d{2}", due_date):
task_data["due_date"] = due_date
anchor = (row.get("interval_anchor") or "").strip()
if anchor in ("planned", "completion"):
task_data["interval_anchor"] = anchor
# schedule_time round-trip with strict HH:MM validation; malformed
# values are dropped silently (consistent with other CSV import fields).
sched_time = (row.get("schedule_time") or "").strip()
if sched_time and re.fullmatch(r"^([01]\d|2[0-3]):[0-5]\d$", sched_time):
task_data["schedule_time"] = sched_time
reading_unit = (row.get("reading_unit") or "").strip()
if reading_unit:
task_data["reading_unit"] = reading_unit[:32]
last_performed = (row.get("last_performed") or "").strip()
if last_performed:
task_data["last_performed"] = last_performed
notes = (row.get("notes") or "").strip()
if notes:
task_data["notes"] = notes
# Optional fields (backwards-compatible — missing columns default to empty)
if (row.get("enabled") or "").strip().lower() == "false":
task_data["enabled"] = False
doc_url = (row.get("documentation_url") or "").strip()
if doc_url:
from urllib.parse import urlparse
scheme = urlparse(doc_url).scheme.lower()
if scheme in ("", "http", "https"):
task_data["documentation_url"] = doc_url
custom_icon = (row.get("custom_icon") or "").strip()
if custom_icon:
task_data["custom_icon"] = custom_icon
nfc_tag = (row.get("nfc_tag_id") or "").strip()
if nfc_tag:
task_data["nfc_tag_id"] = nfc_tag
resp_user = (row.get("responsible_user_id") or "").strip()
if resp_user:
task_data["responsible_user_id"] = resp_user
# Checklist round-trips via a single cell with "\n" between items.
# Apply the same hard caps as the WebSocket schema so a malicious or
# accidental CSV can't bloat the entry.
checklist_raw = row.get("checklist") or ""
if checklist_raw:
items = [line.strip()[:MAX_CHECKLIST_ITEM_LENGTH] for line in checklist_raw.splitlines() if line.strip()][
:MAX_CHECKLIST_ITEMS
]
if items:
task_data["checklist"] = items
objects_map[obj_name]["tasks"][task_id] = task_data
objects_map[obj_name]["object"]["task_ids"].append(task_id)
return list(objects_map.values())
def _safe_int(value: str | None, default: int | None) -> int | None:
"""Safely convert a string to int."""
if value is None:
return default
try:
return int(float(value))
except (ValueError, TypeError):
return default