257 files

This commit is contained in:
Home Assistant Version Control
2026-10-02 16:06:24 +00:00
parent 7c55603942
commit c29f5cc088
258 changed files with 24621 additions and 3136 deletions
@@ -27,6 +27,7 @@ from ..const import (
)
from ..helpers.aggregate import get_object_entries, get_runtime_data, get_store, is_object_entry, object_name, task_entity_id
from ..helpers.aggregate import object_slug as aggregate_object_slug
from ..helpers.parts import MAX_CONSUME_QUANTITY
from ..helpers.phases import current_phase_summary
from ..helpers.ws_errors import send_translated_error
@@ -66,7 +67,7 @@ USED_PARTS_FIELD: Final[vol.Any] = vol.Any(
vol.Required("part_id"): ID_FIELD,
vol.Optional("entry_id"): ID_FIELD,
vol.Optional("quantity", default=1): vol.All(
vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=999)
vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=MAX_CONSUME_QUANTITY)
),
}
)
@@ -230,6 +231,10 @@ def _build_task_summary(
"archived": task_data.get("archived_at") is not None,
"archived_at": task_data.get("archived_at"),
"archived_reason": task_data.get("archived_reason"),
# #193: this task's own pause (a paused OBJECT is on the object).
"paused": task_data.get("paused_at") is not None,
"paused_at": task_data.get("paused_at"),
"paused_until": task_data.get("paused_until"),
"responsible_user_id": task_data.get("responsible_user_id"),
"assignee_pool": task_data.get("assignee_pool", []),
"required_completion_fields": task_data.get("required_completion_fields", []),
@@ -808,9 +813,11 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_duplicate_task,
ws_list_tasks,
ws_move_task,
ws_pause_task,
ws_postpone_task,
ws_quick_complete_task,
ws_reset_task,
ws_resume_task,
ws_set_task_phase,
ws_skip_task,
ws_snooze_task,
@@ -848,6 +855,8 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_move_task)
websocket_api.async_register_command(hass, ws_archive_task)
websocket_api.async_register_command(hass, ws_unarchive_task)
websocket_api.async_register_command(hass, ws_pause_task)
websocket_api.async_register_command(hass, ws_resume_task)
websocket_api.async_register_command(hass, ws_list_tasks)
websocket_api.async_register_command(hass, ws_task_history)
websocket_api.async_register_command(hass, ws_complete_task)
@@ -25,7 +25,6 @@ from ..const import (
CONF_ADVANCED_COMPLETION_ACTIONS,
CONF_ADVANCED_ENVIRONMENTAL,
CONF_ADVANCED_GROUPS,
CONF_ADVANCED_PREDICTIONS,
CONF_ADVANCED_SCHEDULE_TIME,
CONF_ADVANCED_SEASONAL,
CONF_ARCHIVE_ONEOFF_DAYS,
@@ -68,6 +67,7 @@ from ..const import (
CONF_PANEL_ENABLED,
CONF_PANEL_TITLE,
CONF_PART_SEARCH_URL_TEMPLATE,
CONF_PARTS_COST_MODE,
CONF_QUIET_HOURS_ENABLED,
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
@@ -208,7 +208,6 @@ def _build_full_settings(
return {
"features": {
"adaptive": _opt(options, CONF_ADVANCED_ADAPTIVE),
"predictions": _opt(options, CONF_ADVANCED_PREDICTIONS),
"seasonal": _opt(options, CONF_ADVANCED_SEASONAL),
"environmental": _opt(options, CONF_ADVANCED_ENVIRONMENTAL),
"budget": _opt(options, CONF_ADVANCED_BUDGET),
@@ -326,6 +325,8 @@ def _build_full_settings(
"yearly": _opt(options, CONF_BUDGET_YEARLY),
"alerts_enabled": _opt(options, CONF_BUDGET_ALERTS_ENABLED),
"alert_threshold_pct": _opt(options, CONF_BUDGET_ALERT_THRESHOLD),
# #104: purchase | use — when spare parts count as spending.
"parts_cost_mode": _opt(options, CONF_PARTS_COST_MODE),
**_currency_block(options),
},
# Vacation mode (v1.2.0). Mirror the active flag so the panel can
@@ -783,7 +784,10 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
# v1.4.0 (#44): enum-validate notification_title_style. Anything outside
# the known set is dropped silently so a bogus value can't get into the
# ConfigEntry options.
from ..const import HOME_TYPES, NOTIFICATION_TITLE_STYLES, NOTIFY_COMPLETED_MODES
from ..const import HOME_TYPES, NOTIFICATION_TITLE_STYLES, NOTIFY_COMPLETED_MODES, PARTS_COST_MODES
if CONF_PARTS_COST_MODE in filtered and filtered[CONF_PARTS_COST_MODE] not in PARTS_COST_MODES:
del filtered[CONF_PARTS_COST_MODE]
if CONF_HOME_TYPE in filtered and filtered[CONF_HOME_TYPE] not in HOME_TYPES:
del filtered[CONF_HOME_TYPE]
File diff suppressed because it is too large Load Diff
@@ -461,6 +461,19 @@ async def ws_update_object(
# 2.19: entity->device attachment only changes on entity re-add, so a
# changed link/parent needs an entry reload (scheduled below).
device_link_changed = False
swap_report = None
new_device = msg.get("ha_device_id")
if new_device and new_device != obj.get("ha_device_id"):
# A new device for this object (the appliance was replaced, or
# re-paired and came back under a new id): its sensor triggers,
# completion actions and adopted-task fingerprints follow it.
from ..helpers.device_swap import move_tasks_to_device, old_device_ids
tasks = dict(new_data.get(CONF_TASKS) or {})
moved_tasks, swap_report = move_tasks_to_device(
hass, tasks, old_device_ids(obj, tasks.values(), new_device), new_device
)
new_data[CONF_TASKS] = moved_tasks
for key in ("ha_device_id", "parent_entry_id"):
if key in msg and msg[key] != obj.get(key):
obj[key] = msg[key]
@@ -475,7 +488,10 @@ async def ws_update_object(
if device_link_changed:
hass.config_entries.async_schedule_reload(entry.entry_id)
connection.send_result(msg["id"], {"success": True})
result: dict[str, Any] = {"success": True}
if swap_report is not None:
result["device_swap"] = swap_report.as_dict()
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
@@ -902,6 +918,10 @@ async def ws_resume_object(
vol.Required("type"): "maintenance_supporter/object/replace",
vol.Required("entry_id"): ID_FIELD,
vol.Optional("name"): vol.Any(vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)), None),
# The new unit's device: omitted = the same device as the old unit
# (a controller or smart plug that stays), an id = the new unit's own
# device (the wiring follows it), null = none yet.
vol.Optional("ha_device_id"): vol.Any(ID_FIELD, None),
}
)
@require_write
@@ -933,6 +953,8 @@ async def ws_replace_object(
return
name = (msg.get("name") or "").strip() or str(src_obj.get(CONF_OBJECT_NAME, "")).strip() or "Object"
if not _validate_device_link(hass, connection, msg, self_entry_id=None):
return
new_obj = deepcopy(dict(src_obj))
new_obj["id"] = uuid4().hex
@@ -972,6 +994,20 @@ async def ws_replace_object(
new_tasks[task_id] = task
new_obj["task_ids"].append(task_id)
# The new unit is usually a new device in Home Assistant, not the old
# one: carried over verbatim, the successor kept watching the retired
# machine's sensors and pressing its reset button.
swap_report = None
if "ha_device_id" in msg:
new_device = msg["ha_device_id"]
if new_device and new_device != src_obj.get("ha_device_id"):
from ..helpers.device_swap import move_tasks_to_device, old_device_ids
new_tasks, swap_report = move_tasks_to_device(
hass, new_tasks, old_device_ids(src_obj, new_tasks.values(), new_device), new_device
)
new_obj["ha_device_id"] = new_device
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "websocket"},
@@ -1029,7 +1065,10 @@ async def ws_replace_object(
hass.config_entries.async_update_entry(entry, data=retired)
await hass.config_entries.async_reload(entry.entry_id)
connection.send_result(msg["id"], {"entry_id": new_entry_id})
reply: dict[str, Any] = {"entry_id": new_entry_id}
if swap_report is not None:
reply["device_swap"] = swap_report.as_dict()
connection.send_result(msg["id"], reply)
@websocket_api.websocket_command(
@@ -43,8 +43,11 @@ _PART_FIELDS_SCHEMA: VolDictType = {
vol.Optional("notes"): vol.Any(str, None),
vol.Optional("unit"): vol.Any(str, None),
vol.Optional("cost"): vol.Any(int, float, None),
vol.Optional("reorder_threshold"): vol.Any(int, None),
# Decimal thresholds (#98: "0.5 cans") — the schema said int and refused
# the 0.5 the panel sends, although normalize_part accepts it.
vol.Optional("reorder_threshold"): vol.Any(int, float, None),
vol.Optional("restock_quantity"): vol.Any(int, float, None),
vol.Optional("package_size"): vol.Any(int, float, None),
vol.Optional("auto_buy_task"): bool,
vol.Optional("doc_id"): vol.Any(ID_FIELD, None),
# Initial / edited stock travels WITH the definition for dialog simplicity,
@@ -236,7 +239,7 @@ async def ws_delete_part(
# Either a relative delta (may be negative for corrections) or an
# absolute count — exactly one.
vol.Optional("delta"): vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=-MAX_PART_STOCK, max=MAX_PART_STOCK)),
vol.Optional("absolute"): vol.All(int, vol.Range(min=0, max=MAX_PART_STOCK)),
vol.Optional("absolute"): vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=0, max=MAX_PART_STOCK)),
}
)
@require_write
@@ -30,6 +30,8 @@ from .tasks_lifecycle import (
_is_recurring_schedule,
ws_archive_task,
ws_list_tasks,
ws_pause_task,
ws_resume_task,
ws_task_history,
ws_unarchive_task,
)
@@ -70,9 +72,11 @@ __all__ = [
"ws_duplicate_task",
"ws_list_tasks",
"ws_move_task",
"ws_pause_task",
"ws_postpone_task",
"ws_quick_complete_task",
"ws_reset_task",
"ws_resume_task",
"ws_set_task_phase",
"ws_skip_task",
"ws_snooze_task",
@@ -21,6 +21,7 @@ from ..const import (
MAX_TIMESTAMP_LENGTH,
)
from ..helpers.completion_photos import MAX_COMPLETION_PHOTOS, normalize_photo_doc_ids
from ..helpers.parts import MAX_PART_STOCK
from ..helpers.ws_errors import send_exception_error, send_translated_error
from ..models.maintenance_task import MaintenanceTask
from . import (
@@ -106,7 +107,7 @@ def _refuse_too_early(connection: websocket_api.ActiveConnection, msg: dict[str,
vol.Optional("reading_values"): READING_VALUES_FIELD,
# Spare parts: on an auto-created "buy" task, how many units were
# actually bought (dialog override of the part's restock_quantity).
vol.Optional("restock_quantity"): vol.Any(vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=9999)), None),
vol.Optional("restock_quantity"): vol.Any(vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=MAX_PART_STOCK)), None),
# #99: the parts actually used on THIS completion. An explicit list
# (even an empty one) REPLACES the task's automatic consumes_parts
# deduction; omitting the key keeps the automatic behaviour.
@@ -459,7 +460,8 @@ 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 ..const import DOMAIN, NOTIFICATION_MANAGER_KEY
from ..const import CONF_SNOOZE_DURATION_HOURS, DOMAIN, NOTIFICATION_MANAGER_KEY
from ..helpers.global_options import global_option
if _load_object_task(hass, connection, msg) is None:
return
@@ -468,8 +470,11 @@ async def ws_snooze_task(
if nm is None:
send_translated_error(connection, msg["id"], "unavailable", "Notifications not configured", translation_key="notify_not_configured")
return
nm.snooze_task(msg["entry_id"], msg["task_id"])
connection.send_result(msg["id"], {"success": True})
until = nm.snooze_task(msg["entry_id"], msg["task_id"])
# #193: the panel names the duration — "Snoozed" alone left users
# wondering what happened and for how long.
hours = global_option(hass, CONF_SNOOZE_DURATION_HOURS)
connection.send_result(msg["id"], {"success": True, "hours": hours, "snoozed_until": until.isoformat()})
@websocket_api.websocket_command(
@@ -249,6 +249,10 @@ async def ws_update_history_entry(
patched["used_parts"] = enriched
else:
patched.pop("used_parts", None)
# #104: the entry's parts value follows the corrected selection.
from ..helpers.parts_cost import refresh_parts_cost
refresh_parts_cost(patched)
history[target_index] = patched
store.set_history(task_id, history)
@@ -12,17 +12,20 @@ from homeassistant.util import dt as dt_util
from ..const import (
ARCHIVE_REASON_MANUAL,
CONF_TASKS,
MAX_DATE_LENGTH,
)
from ..helpers.aggregate import get_coordinator_data, get_store, object_name
from ..helpers.entry_tasks import write_task
from ..helpers.pause import clear_cycle_modifiers, reanchor_recurring_task
from ..helpers.permissions import require_write
from ..helpers.ws_errors import send_translated_error
from . import (
ID_FIELD,
_build_task_summary,
_get_merged_tasks,
_get_object_entries,
_load_object_task,
_parse_iso_date,
)
@@ -80,6 +83,109 @@ async def ws_archive_task(
connection.send_result(msg["id"], {"success": True, "archived_at": td["archived_at"]})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/pause",
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
vol.Optional("until"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
}
)
@require_write
@websocket_api.async_response
async def ws_pause_task(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Pause ONE task (#193): frozen schedule, no reminders, until resumed.
The object pause, per task — for a filter that comes out of use for a
while (items used in rotation): resuming starts a fresh cycle, so the
interval counts from the day it goes back in. ``until`` (optional future
date) resumes it on that day.
"""
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
entry, _rd, task = ctx
task_id = msg["task_id"]
td = dict(task)
if td.get("archived_at") is not None:
send_translated_error(connection, msg["id"], "archived", "An archived task cannot be paused", translation_key="archived_task_cannot_pause")
return
if td.get("paused_at") is not None:
connection.send_error(msg["id"], "already_paused", "Task already paused")
return
until = msg.get("until")
if until:
until_date = _parse_iso_date(connection, msg["id"], until, field="until")
if until_date is None:
return
if until_date <= dt_util.now().date():
send_translated_error(connection, msg["id"], "invalid_date", "until must be a future date", translation_key="until_in_past")
return
td["paused_at"] = dt_util.now().isoformat()
td["paused_until"] = until or None
write_task(hass, entry, task_id, td)
# Reload: a sensor task's triggers tear down (paused = inert) and every
# per-task entity repaints as paused.
await hass.config_entries.async_reload(entry.entry_id)
connection.send_result(msg["id"], {"success": True, "paused_at": td["paused_at"], "paused_until": td["paused_until"]})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/resume",
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
}
)
@require_write
@websocket_api.async_response
async def ws_resume_task(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""End a task's pause: a recurring task restarts a fresh cycle today."""
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
entry, _rd, td = ctx
task_id = msg["task_id"]
if td.get("paused_at") is None:
connection.send_error(msg["id"], "not_paused", "Task is not paused")
return
# The unarchive order: the Store anchor first (its save is the only
# await), then the static dict from a FRESH read, so a writer landing
# during the disk write cannot be reverted by a stale copy.
store = get_store(hass, entry.entry_id)
recurring = td.get("archived_at") is None and _is_recurring_schedule(td)
today_iso = dt_util.now().date().isoformat()
if recurring and store is not None:
reanchor_recurring_task(task_id, store=store, today_iso=today_iso)
await store.async_save()
fresh = entry.data.get(CONF_TASKS, {}).get(task_id)
if fresh is None:
connection.send_error(msg["id"], "not_found", "Task not found")
return
td = dict(fresh)
td.pop("paused_at", None)
td.pop("paused_until", None)
if recurring and store is None:
reanchor_recurring_task(task_id, store=None, today_iso=today_iso, task_data=td)
elif recurring:
# The Store holds the fresh anchor; scrub the static shadow too.
clear_cycle_modifiers(td)
write_task(hass, entry, task_id, td)
await hass.config_entries.async_reload(entry.entry_id)
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/unarchive",
@@ -23,10 +23,17 @@ from ..helpers.entry_tasks import insert_new_task
from ..helpers.global_options import get_default_warning_days
from ..helpers.sanitize import cap_task_fields
from ..helpers.schedule import (
KIND_INTERVAL,
KIND_MANUAL,
KIND_ONE_TIME,
Schedule,
normalize_task_storage,
)
_TIME_BASED = "time_based"
# The flat service schedule_type → the Schedule kind it means.
_FLAT_TO_KIND = {_TIME_BASED: KIND_INTERVAL, KIND_ONE_TIME: KIND_ONE_TIME, KIND_MANUAL: KIND_MANUAL}
# ---------------------------------------------------------------------------
# Task CRUD
# ---------------------------------------------------------------------------
@@ -46,6 +53,53 @@ def _checked_due_date(value: Any) -> str:
return parsed.isoformat()
def _check_schedule_request(
schedule_type: str | None,
*,
interval_days: Any,
schedule: dict[str, Any] | None,
stored_kind: str | None = None,
) -> None:
"""Refuse a recurrence request that contradicts itself.
The storage model resolves a contradiction by picking one side, so the
services used to report success for a task that was quietly something
else (bug audit 2026-09-29): ``time_based`` without an interval made a
manual task (a weekday or one-time task lost its schedule), ``manual``
with ``interval_days`` an interval task, ``one_time`` with an interval
``schedule`` a one-time task without a date. ``stored_kind`` is the
task's current kind on an edit — an interval task switched to
``time_based`` keeps its interval. Raises ValueError (the services report
it as invalid input).
"""
if schedule_type is None:
return
if schedule:
parsed = Schedule.from_dict(schedule)
kind = parsed.kind
if parsed.is_calendar_kind:
if schedule_type != _TIME_BASED:
raise ValueError(f"schedule_type {schedule_type!r} contradicts the {kind!r} schedule (use time_based)")
elif _FLAT_TO_KIND.get(schedule_type) != kind:
raise ValueError(f"schedule_type {schedule_type!r} contradicts the schedule's kind {kind!r}")
return
if schedule_type == KIND_MANUAL and interval_days is not None:
raise ValueError("A manual task has no interval — leave interval_days out")
if schedule_type == _TIME_BASED and interval_days is None and stored_kind != KIND_INTERVAL:
raise ValueError("A time-based task needs interval_days")
def _implied_schedule_type(*, interval_days: Any, due_date: Any, schedule: dict[str, Any] | None) -> str:
"""What an ``add_task`` call without ``schedule_type`` asks for: the kind
its other fields describe (a bare name stays a manual task, as before)."""
if schedule:
kind = Schedule.from_dict(schedule).kind
return kind if kind in (KIND_ONE_TIME, KIND_MANUAL) else _TIME_BASED
if due_date:
return KIND_ONE_TIME
return _TIME_BASED if interval_days is not None else KIND_MANUAL
async def async_persist_task(
hass: HomeAssistant,
entry: ConfigEntry,
@@ -77,7 +131,7 @@ async def async_create_task_simple(
entry_id: str,
name: str,
task_type: str = "custom",
schedule_type: str = "time_based",
schedule_type: str | None = None,
interval_days: int | None = None,
interval_unit: str = "days",
due_date: str | None = None,
@@ -94,7 +148,9 @@ async def async_create_task_simple(
the panel / card dialogs or the ``task/create`` WS command.
Raises ValueError if the entry_id is not a maintenance object, the name
is empty or the due date is not a date.
is empty, the due date is not a date or the recurrence contradicts itself
(:func:`_check_schedule_request`). Without ``schedule_type`` the kind the
other fields describe is created — a bare name stays a manual task.
``warning_days`` defaults to the integration-wide setting like every
other create path — it was the bare constant 7 here (bug audit
@@ -112,6 +168,12 @@ async def async_create_task_simple(
name = (name or "").strip()
if not name:
raise ValueError("Name must not be empty")
if schedule_type is None:
schedule_type = _implied_schedule_type(interval_days=interval_days, due_date=due_date, schedule=schedule)
else:
_check_schedule_request(schedule_type, interval_days=interval_days, schedule=schedule)
if schedule_type == KIND_ONE_TIME and not due_date and not (schedule and Schedule.from_dict(schedule).due_date):
raise ValueError("A one-time task needs a due_date")
task_id = uuid4().hex
task_data: dict[str, Any] = {
"id": task_id,
@@ -191,6 +253,13 @@ async def async_update_task_simple(
raise ValueError(f"No task {task_id!r} in {entry.title!r}")
task = dict(new_tasks[task_id])
stored_kind = Schedule.parse(new_tasks[task_id]).kind
_check_schedule_request(
updates.get("schedule_type"),
interval_days=updates.get("interval_days"),
schedule=updates.get("schedule"),
stored_kind=stored_kind,
)
if updates.get("due_date") is not None:
updates = {**updates, "due_date": _checked_due_date(updates["due_date"])}
for key in _UPDATABLE_FLAT_FIELDS:
@@ -222,6 +291,21 @@ async def async_update_task_simple(
# task/update — the flat fields rebuild the schedule (bug audit
# 2026-09-26).
task.pop("schedule", None)
new_kind = updates.get("schedule_type")
if (
new_kind in (KIND_MANUAL, KIND_ONE_TIME)
and not updates.get("schedule")
and (new_kind != stored_kind or new_kind == KIND_MANUAL)
):
# A switch to manual / one-time: the stored interval rode along into
# the flat overlay and rebuilt an interval, so the switch silently did
# nothing (audit 2026-09-29). The old recurrence goes; a one-time task
# needs its date.
if new_kind == KIND_ONE_TIME and not task.get("due_date"):
raise ValueError("A one-time task needs a due_date")
task.pop("schedule", None)
for key in ("interval_days", "interval_unit", "interval_anchor"):
task.pop(key, None)
# The service cannot change the completion action, so the stored one keeps
# the user it runs as — dropping ``configured_by`` here made an operator's
@@ -93,6 +93,7 @@ _TRIGGER_REQUIRED_FIELDS: dict[str, list[str]] = {
"counter": ["trigger_target_value"],
"state_change": [],
"runtime": ["trigger_runtime_hours"],
"due_date": [], # trigger_days_before defaults to 0 = on the date itself
"compound": [], # conditions validated separately
}
@@ -120,6 +121,8 @@ _TRIGGER_ALLOWED_KEYS: set[str] = {
"trigger_from_state",
"trigger_to_state",
"trigger_target_changes",
# due_date
"trigger_days_before",
# compound
"compound_logic",
"conditions",
@@ -273,6 +276,7 @@ _OPTIONAL_VALUE_FIELDS: tuple[str, ...] = (
"trigger_baseline_value",
"trigger_delta_mode",
"trigger_runtime_hours",
"trigger_days_before",
)