393 files
This commit is contained in:
@@ -17,13 +17,13 @@ from ..const import (
|
||||
MAX_COST,
|
||||
MAX_DATE_LENGTH,
|
||||
MAX_DURATION_MINUTES,
|
||||
MAX_ID_LENGTH,
|
||||
MAX_TEXT_LENGTH,
|
||||
MAX_TIMESTAMP_LENGTH,
|
||||
)
|
||||
from ..helpers.completion_photos import MAX_COMPLETION_PHOTOS, normalize_photo_doc_ids
|
||||
from ..models.maintenance_task import MaintenanceTask
|
||||
from . import (
|
||||
ID_FIELD,
|
||||
READING_VALUES_FIELD,
|
||||
USED_PARTS_FIELD,
|
||||
_load_object_task,
|
||||
@@ -66,8 +66,8 @@ def _refuse_too_early(connection: websocket_api.ActiveConnection, msg: dict[str,
|
||||
@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.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
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=MAX_COST)), None),
|
||||
vol.Optional("duration"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)), None),
|
||||
@@ -93,10 +93,10 @@ def _refuse_too_early(connection: websocket_api.ActiveConnection, msg: dict[str,
|
||||
# images (via the document upload endpoint, tagged "photo"). The
|
||||
# scalar form is what pre-2.75 clients send — merged into the list.
|
||||
vol.Optional("photo_doc_ids"): vol.Any(
|
||||
vol.All([vol.All(str, vol.Length(max=MAX_ID_LENGTH))], vol.Length(max=MAX_COMPLETION_PHOTOS)),
|
||||
vol.All([ID_FIELD], vol.Length(max=MAX_COMPLETION_PHOTOS)),
|
||||
None,
|
||||
),
|
||||
vol.Optional("photo_doc_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
|
||||
vol.Optional("photo_doc_id"): vol.Any(ID_FIELD, 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),
|
||||
@@ -177,6 +177,17 @@ async def ws_complete_task(
|
||||
connection.send_error(msg["id"], "invalid_input", str(err))
|
||||
return
|
||||
|
||||
# Only this object's uploaded files count as completion photos — a
|
||||
# foreign doc id satisfied a required photo and got linked (and later
|
||||
# re-homed by task/move). This command is the one completion surface
|
||||
# that carries photos (bug audit 2026-09-26).
|
||||
from ..helpers.completion_requirements import own_photo_doc_ids
|
||||
from . import object_id_for_entry
|
||||
|
||||
photo_doc_ids = own_photo_doc_ids(
|
||||
hass, object_id_for_entry(_entry), normalize_photo_doc_ids(msg.get("photo_doc_ids"), msg.get("photo_doc_id"))
|
||||
)
|
||||
|
||||
try:
|
||||
await rd.coordinator.complete_maintenance(
|
||||
source="panel",
|
||||
@@ -186,7 +197,7 @@ async def ws_complete_task(
|
||||
duration=msg.get("duration"),
|
||||
checklist_state=msg.get("checklist_state"),
|
||||
feedback=msg.get("feedback"),
|
||||
photo_doc_ids=normalize_photo_doc_ids(msg.get("photo_doc_ids"), msg.get("photo_doc_id")) or None,
|
||||
photo_doc_ids=photo_doc_ids or None,
|
||||
reading_value=msg.get("reading_value"),
|
||||
reading_values=reading_values,
|
||||
restock_quantity=msg.get("restock_quantity"),
|
||||
@@ -219,8 +230,8 @@ async def ws_complete_task(
|
||||
@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)),
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -273,8 +284,8 @@ async def ws_quick_complete_task(
|
||||
@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.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
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.
|
||||
@@ -310,8 +321,8 @@ async def ws_skip_task(
|
||||
@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.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
vol.Optional("date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
|
||||
}
|
||||
)
|
||||
@@ -329,7 +340,10 @@ async def ws_reset_task(
|
||||
|
||||
reset_date = None
|
||||
if msg.get("date"):
|
||||
reset_date = _parse_iso_date(connection, msg["id"], msg["date"], field="date")
|
||||
# A reset marks a day the work WAS done — a future one (read tier:
|
||||
# any household member) overflowed the schedule math and took the
|
||||
# object down (bug audit 2026-09-27).
|
||||
reset_date = _parse_iso_date(connection, msg["id"], msg["date"], field="date", not_future=True)
|
||||
if reset_date is None:
|
||||
return
|
||||
|
||||
@@ -348,8 +362,8 @@ async def ws_reset_task(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/set_phase",
|
||||
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("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
# Index into phase_sequence — which cycle step is due NEXT.
|
||||
vol.Required("cursor"): vol.All(int, vol.Range(min=0, max=100)),
|
||||
}
|
||||
@@ -387,8 +401,8 @@ async def ws_set_task_phase(
|
||||
@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("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
vol.Required("until"): vol.All(str, vol.Length(max=MAX_DATE_LENGTH)),
|
||||
}
|
||||
)
|
||||
@@ -407,6 +421,15 @@ async def ws_postpone_task(
|
||||
until = _parse_iso_date(connection, msg["id"], msg["until"], field="until")
|
||||
if until is None:
|
||||
return
|
||||
# Bug audit 2026-09-27: an override in year 9999 overflowed the calendar
|
||||
# entity's "next day" — a postpone reaches at most one maximum interval out.
|
||||
from datetime import timedelta
|
||||
|
||||
from ..const import MAX_INTERVAL_DAYS
|
||||
|
||||
if until > dt_util.now().date() + timedelta(days=MAX_INTERVAL_DAYS):
|
||||
connection.send_error(msg["id"], "invalid_date", f"until must be within {MAX_INTERVAL_DAYS} days")
|
||||
return
|
||||
|
||||
try:
|
||||
await rd.coordinator.async_postpone_task(msg["task_id"], until)
|
||||
@@ -419,8 +442,8 @@ async def ws_postpone_task(
|
||||
@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)),
|
||||
vol.Required("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -435,7 +458,7 @@ async def ws_snooze_task(
|
||||
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
|
||||
from ..const import DOMAIN, NOTIFICATION_MANAGER_KEY
|
||||
|
||||
if _load_object_task(hass, connection, msg) is None:
|
||||
return
|
||||
@@ -451,8 +474,8 @@ async def ws_snooze_task(
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/task/checklist_progress",
|
||||
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("entry_id"): ID_FIELD,
|
||||
vol.Required("task_id"): ID_FIELD,
|
||||
# Same shape/caps as task/complete's checklist_state.
|
||||
vol.Required("checklist_state"): vol.All(
|
||||
{vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH)): bool},
|
||||
@@ -478,11 +501,16 @@ async def ws_checklist_progress(
|
||||
a step IS doing the work — the same household member who may complete the
|
||||
task must be able to record partial progress.
|
||||
"""
|
||||
ctx = _load_object_task(hass, connection, msg, need_coordinator=True)
|
||||
from ..helpers.phases import effective_field
|
||||
|
||||
# Merged view: the phase cursor lives in the Store. A phased task (#139)
|
||||
# shows the CURRENT phase's checklist; validating against the task-level
|
||||
# list dropped every tick on it (bug audit 2026-09-26).
|
||||
ctx = _load_object_task(hass, connection, msg, merged=True, need_coordinator=True)
|
||||
if ctx is None:
|
||||
return
|
||||
_entry, rd, task = ctx
|
||||
task_items = set(task.get("checklist") or [])
|
||||
task_items = set(effective_field(task, "checklist") or [])
|
||||
state = {item: bool(done) for item, done in msg["checklist_state"].items() if item in task_items}
|
||||
# Progress lives ONLY in the Store (no legacy fallback) — degrade to a
|
||||
# clean error instead of an AttributeError when it failed to load.
|
||||
|
||||
Reference in New Issue
Block a user