New apps Added
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user