"""Task persistence primitives shared by CRUD + the add_task service.""" from __future__ import annotations from typing import Any from uuid import uuid4 from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from ..const import ( CONF_OBJECT, CONF_TASKS, DEFAULT_WARNING_DAYS, DOMAIN, GLOBAL_UNIQUE_ID, MAX_TASKS_PER_OBJECT, ) from ..helpers.sanitize import cap_task_fields from ..helpers.schedule import ( normalize_task_storage, ) from . import ( _get_runtime_data, ) # --------------------------------------------------------------------------- # Task CRUD # --------------------------------------------------------------------------- async def async_persist_task( hass: HomeAssistant, entry: ConfigEntry, task_data: dict[str, Any], *, last_performed: str | None = None, history: list[dict[str, Any]] | None = None, ) -> None: """Persist a freshly-built task into an object entry and reload it. Shared by the ``task/create`` WS command and the ``add_task`` service (DRY): updates ConfigEntry.data + the object's task_ids, initializes the Store dynamic state, and reloads the entry so the task's entities (sensor / binary_sensor / buttons) are created. """ # Store recurrence in the canonical nested `schedule` shape (schedule-model v2). task_data = normalize_task_storage(task_data) task_id = task_data["id"] # Per-object task cap — this is the single create chokepoint for BOTH the # task/create WS command and the add_task service, so one guard covers both. # The ValueError surfaces as a WS error / a service ValidationError at the # callers (a runaway automation can't inflate ConfigEntry.data without bound). existing_tasks = entry.data.get(CONF_TASKS, {}) if task_id not in existing_tasks and len(existing_tasks) >= MAX_TASKS_PER_OBJECT: raise ValueError(f"This object already has the maximum of {MAX_TASKS_PER_OBJECT} tasks") new_data = dict(entry.data) new_tasks = dict(new_data.get(CONF_TASKS, {})) new_tasks[task_id] = task_data new_data[CONF_TASKS] = new_tasks obj = dict(new_data.get(CONF_OBJECT, {})) task_ids = list(obj.get("task_ids", [])) task_ids.append(task_id) obj["task_ids"] = task_ids new_data[CONF_OBJECT] = obj hass.config_entries.async_update_entry(entry, data=new_data) rd = _get_runtime_data(hass, entry.entry_id) store = getattr(rd, "store", None) if rd else None if store is not None: store.init_task(task_id, last_performed=last_performed) if history: store.set_history(task_id, history) await store.async_save() else: # Legacy: dynamic fields live in ConfigEntry.data task_data["last_performed"] = last_performed task_data["history"] = history or [] new_tasks[task_id] = task_data new_data[CONF_TASKS] = new_tasks hass.config_entries.async_update_entry(entry, data=new_data) await hass.config_entries.async_reload(entry.entry_id) async def async_create_task_simple( hass: HomeAssistant, *, entry_id: str, name: str, task_type: str = "custom", schedule_type: str = "time_based", interval_days: int | None = None, interval_unit: str = "days", due_date: str | None = None, warning_days: int = DEFAULT_WARNING_DAYS, enabled: bool = True, notes: str | None = None, schedule: dict[str, Any] | None = None, ) -> str: """Create a task with the common fields and persist it; return task_id. The service-facing creation path — a focused subset of ws_create_task's field set — sharing :func:`async_persist_task` with the WS handler (DRY). For the full field set (triggers, checklists, completion actions, …) use the panel / card dialogs or the ``task/create`` WS command. Raises ValueError if the entry_id is not a maintenance object or the name is empty. Like the config-flow save handlers (see ``helpers/sanitize``), this runs :func:`cap_task_fields` before persisting: the ``add_task`` *service* schema is the boundary for service callers, but this function is also reachable directly from Python, so the caps can't live only in the schema. """ entry = hass.config_entries.async_get_entry(entry_id) if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID: raise ValueError(f"No maintenance object found for entry_id {entry_id!r}") name = (name or "").strip() if not name: raise ValueError("Name must not be empty") task_data: dict[str, Any] = { "id": uuid4().hex, "object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""), "name": name, "type": task_type, "enabled": enabled, "schedule_type": schedule_type, "warning_days": warning_days, "created_at": dt_util.now().date().isoformat(), } if schedule: # Calendar kinds: persist the nested schedule (normalize treats it as # authoritative over the flat fields). task_data["schedule"] = schedule if interval_days is not None: task_data["interval_days"] = interval_days if interval_unit and interval_unit != "days": task_data["interval_unit"] = interval_unit if due_date: task_data["due_date"] = due_date if notes: task_data["notes"] = notes # Same sanitising as the config-flow create path, applied BEFORE the # storage normalisation inside async_persist_task so a capped # interval_days/warning_days is what the schedule model sees. cap_task_fields(task_data) await async_persist_task(hass, entry, task_data) return task_data["id"] _UPDATABLE_FLAT_FIELDS = ( "name", "type", "interval_days", "interval_unit", "due_date", "warning_days", "enabled", "notes", "priority", "labels", ) async def async_update_task_simple( hass: HomeAssistant, *, entry_id: str, task_id: str, updates: dict[str, Any], ) -> None: """Patch the common task fields and persist; the service-facing edit path. Mirror of :func:`async_create_task_simple` for edits — a focused subset of the ``task/update`` WS field set for automations/scripts/voice. Present keys in *updates* overwrite; absent keys are untouched. Recurrence changes (flat fields or a nested ``schedule``) go through :func:`normalize_task_storage`, so partial edits keep the unit/anchor semantics of the storage model (issue #58 class). Runs :func:`cap_task_fields` over the MERGED task before persisting — mirroring the options-flow edit path — so a direct Python caller can't write past the caps the ``update_task`` service schema enforces. Raises ValueError for an unknown entry/task or an empty name. """ entry = hass.config_entries.async_get_entry(entry_id) if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID: raise ValueError(f"No maintenance object found for entry_id {entry_id!r}") new_data = dict(entry.data) new_tasks = dict(new_data.get(CONF_TASKS, {})) if task_id not in new_tasks: raise ValueError(f"No task {task_id!r} in {entry.title!r}") task = dict(new_tasks[task_id]) for key in _UPDATABLE_FLAT_FIELDS: if key in updates and updates[key] is not None: task[key] = updates[key] # #128: assignment via the update_task service. "" clears (a user id is # never empty); None keeps the field untouched like everywhere else here. ruid = updates.get("responsible_user_id") if ruid is not None: if ruid: task["responsible_user_id"] = ruid else: task.pop("responsible_user_id", None) if isinstance(task.get("name"), str): task["name"] = task["name"].strip() if not task["name"]: raise ValueError("Name must not be empty") if updates.get("schedule_type") is not None: task["schedule_type"] = updates["schedule_type"] if updates.get("schedule"): task["schedule"] = updates["schedule"] cap_task_fields(task) new_tasks[task_id] = normalize_task_storage(task) new_data[CONF_TASKS] = new_tasks hass.config_entries.async_update_entry(entry, data=new_data) await hass.config_entries.async_reload(entry_id) async def async_move_task( hass: HomeAssistant, source: ConfigEntry, target: ConfigEntry, task_id: str, ) -> None: """Move a task — config AND dynamic state — from one object entry to another. What travels: the task's config (schedule, trigger, checklist, parts links, slug, NFC tag …) and its Store state (history, last_performed, planned due, adaptive config, phase cursor, checklist progress, trigger runtime incl. counter baselines). Group memberships follow the task. What does not: the reference number (``ref_no`` and the history entries' numbers — the target object numbers it afresh on its next refresh) and document links (documents belong to the source object; ``async_delete_task`` unlinks them). Part links keep an explicit ``entry_id`` so a link to one of the source object's parts still resolves as a foreign-pool link. Both entries are reloaded by the caller: the source drops the task's entities, the target creates them under its own object slug. Raises ValueError when the target is full. """ from copy import deepcopy from ..const import MAX_TASKS_PER_OBJECT from .tasks_crud import async_delete_task task_data = deepcopy(dict(source.data[CONF_TASKS][task_id])) task_data.pop("ref_no", None) links = task_data.get("consumes_parts") if isinstance(links, list): task_data["consumes_parts"] = [ {**link, "entry_id": link.get("entry_id") or source.entry_id} if isinstance(link, dict) else link for link in links ] src_rd = _get_runtime_data(hass, source.entry_id) src_store = getattr(src_rd, "store", None) if src_rd else None state = deepcopy(src_store.get_task_state(task_id)) if src_store is not None else {} state.pop("next_history_ref", None) for entry in state.get("history") or []: if isinstance(entry, dict): entry.pop("ref_no", None) # Group memberships: snapshot, let the delete sweep them, re-add under the target. from ..const import CONF_GROUPS from ..helpers.global_options import get_global_entry member_groups: list[str] = [] global_entry = get_global_entry(hass) if global_entry is not None: for gid, group in (dict(global_entry.options or global_entry.data).get(CONF_GROUPS) or {}).items(): if any(isinstance(r, dict) and r.get("task_id") == task_id for r in group.get("task_refs", [])): member_groups.append(gid) existing = target.data.get(CONF_TASKS, {}) if len(existing) >= MAX_TASKS_PER_OBJECT: raise ValueError(f"The target object already has the maximum of {MAX_TASKS_PER_OBJECT} tasks") await async_delete_task(hass, source, task_id) new_data = dict(target.data) new_tasks = dict(new_data.get(CONF_TASKS, {})) new_tasks[task_id] = task_data new_data[CONF_TASKS] = new_tasks obj = dict(new_data.get(CONF_OBJECT, {})) obj["task_ids"] = [*obj.get("task_ids", []), task_id] new_data[CONF_OBJECT] = obj hass.config_entries.async_update_entry(target, data=new_data) tgt_rd = _get_runtime_data(hass, target.entry_id) tgt_store = getattr(tgt_rd, "store", None) if tgt_rd else None if tgt_store is not None: tgt_store.put_task_state(task_id, state) await tgt_store.async_save() if member_groups and global_entry is not None: options = dict(global_entry.options or global_entry.data) groups = dict(options.get(CONF_GROUPS) or {}) for gid in member_groups: group = groups.get(gid) if group is None: continue groups[gid] = {**group, "task_refs": [*group.get("task_refs", []), {"entry_id": target.entry_id, "task_id": task_id}]} options[CONF_GROUPS] = groups hass.config_entries.async_update_entry(global_entry, options=options)