318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""Task action WS handlers: complete / quick_complete / skip / reset."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import voluptuous as vol
|
|
from homeassistant.components import websocket_api
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
from ..const import (
|
|
CONF_TASKS,
|
|
MAX_CHECKLIST_ITEM_LENGTH,
|
|
MAX_CHECKLIST_ITEMS,
|
|
MAX_DATE_LENGTH,
|
|
MAX_ID_LENGTH,
|
|
MAX_TEXT_LENGTH,
|
|
)
|
|
from ..models.maintenance_task import MaintenanceTask
|
|
from . import (
|
|
_get_runtime_data,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task Actions (Complete / Skip / Reset)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _completion_blocked(rd: Any, task_id: str) -> bool:
|
|
"""True iff the task's completion window forbids completing it right now.
|
|
|
|
Uses the coordinator's merged (live) task data so ``last_performed`` /
|
|
``next_due`` reflect the Store, not the stale config entry.
|
|
"""
|
|
coordinator = getattr(rd, "coordinator", None)
|
|
if coordinator is None:
|
|
return False
|
|
merged = coordinator._get_merged_tasks_data()
|
|
td = merged.get(task_id)
|
|
if not td:
|
|
return False
|
|
return not MaintenanceTask.from_dict(td).can_complete_now
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "maintenance_supporter/task/complete",
|
|
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
|
vol.Optional("cost"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0, max=1_000_000)), None),
|
|
vol.Optional("duration"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=525_600)), None),
|
|
# Restrict checklist_state to {string-key (≤500): bool, ...} with
|
|
# a hard cap on entries. Without this, attackers (or bad clients)
|
|
# could inflate the per-task history with arbitrarily large dicts.
|
|
vol.Optional("checklist_state"): vol.Any(
|
|
vol.All(
|
|
{vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH)): bool},
|
|
vol.Length(max=MAX_CHECKLIST_ITEMS),
|
|
),
|
|
None,
|
|
),
|
|
vol.Optional("feedback"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
|
# Optional completion photo: the doc_id of an already-uploaded image
|
|
# (via the document upload endpoint, tagged "photo").
|
|
vol.Optional("photo_doc_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
|
|
# Meter readings (v2.20, #83): the recorded value for `reading` tasks.
|
|
# Wide numeric bounds — meters count high, temperatures go negative.
|
|
vol.Optional("reading_value"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None),
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_complete_task(
|
|
hass: HomeAssistant,
|
|
connection: websocket_api.ActiveConnection,
|
|
msg: dict[str, Any],
|
|
) -> None:
|
|
"""Mark a task as completed."""
|
|
rd = _get_runtime_data(hass, msg["entry_id"])
|
|
if rd is None or rd.coordinator is None:
|
|
connection.send_error(msg["id"], "not_found", "Coordinator not found")
|
|
return
|
|
|
|
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
|
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
|
|
connection.send_error(msg["id"], "not_found", "Task not found")
|
|
return
|
|
|
|
if _completion_blocked(rd, msg["task_id"]):
|
|
connection.send_error(
|
|
msg["id"],
|
|
"too_early",
|
|
"Task can only be completed closer to its due date",
|
|
)
|
|
return
|
|
|
|
await rd.coordinator.complete_maintenance(
|
|
task_id=msg["task_id"],
|
|
notes=msg.get("notes"),
|
|
cost=msg.get("cost"),
|
|
duration=msg.get("duration"),
|
|
checklist_state=msg.get("checklist_state"),
|
|
feedback=msg.get("feedback"),
|
|
photo_doc_id=msg.get("photo_doc_id"),
|
|
reading_value=msg.get("reading_value"),
|
|
)
|
|
connection.send_result(msg["id"], {"success": True})
|
|
|
|
|
|
# v1.3.0: One-tap completion using values pre-configured on the task.
|
|
# Used by the "quick_complete" QR scan path. Falls back with `no_defaults`
|
|
# error when the task has no quick_complete_defaults — frontend then
|
|
# routes the user to the normal complete dialog.
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "maintenance_supporter/task/quick_complete",
|
|
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_quick_complete_task(
|
|
hass: HomeAssistant,
|
|
connection: websocket_api.ActiveConnection,
|
|
msg: dict[str, Any],
|
|
) -> None:
|
|
"""Complete a task using its pre-configured `quick_complete_defaults`."""
|
|
rd = _get_runtime_data(hass, msg["entry_id"])
|
|
if rd is None or rd.coordinator is None:
|
|
connection.send_error(msg["id"], "not_found", "Coordinator not found")
|
|
return
|
|
|
|
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
|
if entry is None:
|
|
connection.send_error(msg["id"], "not_found", "Object not found")
|
|
return
|
|
task = entry.data.get(CONF_TASKS, {}).get(msg["task_id"])
|
|
if not task:
|
|
connection.send_error(msg["id"], "not_found", "Task not found")
|
|
return
|
|
|
|
if _completion_blocked(rd, msg["task_id"]):
|
|
connection.send_error(
|
|
msg["id"],
|
|
"too_early",
|
|
"Task can only be completed closer to its due date",
|
|
)
|
|
return
|
|
|
|
defaults = task.get("quick_complete_defaults") or {}
|
|
if not isinstance(defaults, dict) or not defaults:
|
|
# Frontend fallback: open the normal complete dialog so the user
|
|
# is never stuck staring at a useless QR scan.
|
|
connection.send_error(
|
|
msg["id"],
|
|
"no_defaults",
|
|
"Task has no quick_complete_defaults; open complete dialog instead",
|
|
)
|
|
return
|
|
|
|
await rd.coordinator.complete_maintenance(
|
|
task_id=msg["task_id"],
|
|
notes=defaults.get("notes"),
|
|
cost=defaults.get("cost"),
|
|
duration=defaults.get("duration"),
|
|
feedback=defaults.get("feedback"),
|
|
)
|
|
connection.send_result(msg["id"], {"success": True, "via": "quick"})
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "maintenance_supporter/task/skip",
|
|
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
vol.Optional("reason"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
|
|
# Record the skipped cycle as MISSED (was due, never done) rather than a
|
|
# deliberate skip — clearer history + compliance views.
|
|
vol.Optional("as_missed", default=False): bool,
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_skip_task(
|
|
hass: HomeAssistant,
|
|
connection: websocket_api.ActiveConnection,
|
|
msg: dict[str, Any],
|
|
) -> None:
|
|
"""Skip the current maintenance cycle."""
|
|
rd = _get_runtime_data(hass, msg["entry_id"])
|
|
if rd is None or rd.coordinator is None:
|
|
connection.send_error(msg["id"], "not_found", "Coordinator not found")
|
|
return
|
|
|
|
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
|
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
|
|
connection.send_error(msg["id"], "not_found", "Task not found")
|
|
return
|
|
|
|
await rd.coordinator.skip_maintenance(
|
|
task_id=msg["task_id"],
|
|
reason=msg.get("reason"),
|
|
as_missed=msg.get("as_missed", False),
|
|
)
|
|
connection.send_result(msg["id"], {"success": True})
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "maintenance_supporter/task/reset",
|
|
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
vol.Optional("date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_reset_task(
|
|
hass: HomeAssistant,
|
|
connection: websocket_api.ActiveConnection,
|
|
msg: dict[str, Any],
|
|
) -> None:
|
|
"""Reset the last performed date."""
|
|
from datetime import date as date_cls
|
|
|
|
rd = _get_runtime_data(hass, msg["entry_id"])
|
|
if rd is None or rd.coordinator is None:
|
|
connection.send_error(msg["id"], "not_found", "Coordinator not found")
|
|
return
|
|
|
|
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
|
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
|
|
connection.send_error(msg["id"], "not_found", "Task not found")
|
|
return
|
|
|
|
reset_date = None
|
|
if msg.get("date"):
|
|
try:
|
|
reset_date = date_cls.fromisoformat(msg["date"])
|
|
except ValueError:
|
|
connection.send_error(msg["id"], "invalid_date", "Invalid date format")
|
|
return
|
|
|
|
await rd.coordinator.reset_maintenance(
|
|
task_id=msg["task_id"],
|
|
date=reset_date,
|
|
)
|
|
connection.send_result(msg["id"], {"success": True})
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "maintenance_supporter/task/postpone",
|
|
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
vol.Required("until"): vol.All(str, vol.Length(max=MAX_DATE_LENGTH)),
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_postpone_task(
|
|
hass: HomeAssistant,
|
|
connection: websocket_api.ActiveConnection,
|
|
msg: dict[str, Any],
|
|
) -> None:
|
|
"""Postpone the current occurrence to a chosen date (per-occurrence defer)."""
|
|
from datetime import date as date_cls
|
|
|
|
rd = _get_runtime_data(hass, msg["entry_id"])
|
|
if rd is None or rd.coordinator is None:
|
|
connection.send_error(msg["id"], "not_found", "Coordinator not found")
|
|
return
|
|
|
|
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
|
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
|
|
connection.send_error(msg["id"], "not_found", "Task not found")
|
|
return
|
|
|
|
try:
|
|
until = date_cls.fromisoformat(msg["until"])
|
|
except ValueError:
|
|
connection.send_error(msg["id"], "invalid_date", "Invalid date format")
|
|
return
|
|
|
|
await rd.coordinator.async_postpone_task(msg["task_id"], until)
|
|
connection.send_result(msg["id"], {"success": True})
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): "maintenance_supporter/task/snooze",
|
|
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_snooze_task(
|
|
hass: HomeAssistant,
|
|
connection: websocket_api.ActiveConnection,
|
|
msg: dict[str, Any],
|
|
) -> None:
|
|
"""Snooze a task's notifications for the configured snooze duration.
|
|
|
|
Surfaces the existing notification-action snooze on the panel. Suppresses
|
|
due-soon/overdue/triggered reminders for ``snooze_duration_hours`` — it does
|
|
not change the task's schedule or state.
|
|
"""
|
|
from .. import DOMAIN, NOTIFICATION_MANAGER_KEY
|
|
|
|
entry = hass.config_entries.async_get_entry(msg["entry_id"])
|
|
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
|
|
connection.send_error(msg["id"], "not_found", "Task not found")
|
|
return
|
|
|
|
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
|
if nm is None:
|
|
connection.send_error(msg["id"], "unavailable", "Notifications not configured")
|
|
return
|
|
nm.snooze_task(msg["entry_id"], msg["task_id"])
|
|
connection.send_result(msg["id"], {"success": True})
|