Allow clearing To-do list item extended fields (#106208)

pull/106475/head
Allen Porter 2023-12-27 04:14:59 -08:00 committed by GitHub
parent c51ac7171a
commit 2497798b5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 627 additions and 148 deletions

View File

@ -90,20 +90,6 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None:
) )
def _to_ics_fields(item: TodoItem) -> dict[str, Any]:
"""Convert a TodoItem to the set of add or update arguments."""
item_data: dict[str, Any] = {}
if summary := item.summary:
item_data["summary"] = summary
if status := item.status:
item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION")
if due := item.due:
item_data["due"] = due
if description := item.description:
item_data["description"] = description
return item_data
class WebDavTodoListEntity(TodoListEntity): class WebDavTodoListEntity(TodoListEntity):
"""CalDAV To-do list entity.""" """CalDAV To-do list entity."""
@ -140,9 +126,18 @@ class WebDavTodoListEntity(TodoListEntity):
async def async_create_todo_item(self, item: TodoItem) -> None: async def async_create_todo_item(self, item: TodoItem) -> None:
"""Add an item to the To-do list.""" """Add an item to the To-do list."""
item_data: dict[str, Any] = {}
if summary := item.summary:
item_data["summary"] = summary
if status := item.status:
item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION")
if due := item.due:
item_data["due"] = due
if description := item.description:
item_data["description"] = description
try: try:
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
partial(self._calendar.save_todo, **_to_ics_fields(item)), partial(self._calendar.save_todo, **item_data),
) )
except (requests.ConnectionError, DAVError) as err: except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err raise HomeAssistantError(f"CalDAV save error: {err}") from err
@ -159,10 +154,17 @@ class WebDavTodoListEntity(TodoListEntity):
except (requests.ConnectionError, DAVError) as err: except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
vtodo = todo.icalendar_component # type: ignore[attr-defined] vtodo = todo.icalendar_component # type: ignore[attr-defined]
updated_fields = _to_ics_fields(item) vtodo["SUMMARY"] = item.summary or ""
if "due" in updated_fields: if status := item.status:
todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined] vtodo["STATUS"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION")
vtodo.update(**updated_fields) if due := item.due:
todo.set_due(due) # type: ignore[attr-defined]
else:
vtodo.pop("DUE", None)
if description := item.description:
vtodo["DESCRIPTION"] = description
else:
vtodo.pop("DESCRIPTION", None)
try: try:
await self.hass.async_add_executor_job( await self.hass.async_add_executor_job(
partial( partial(

View File

@ -29,18 +29,20 @@ TODO_STATUS_MAP = {
TODO_STATUS_MAP_INV = {v: k for k, v in TODO_STATUS_MAP.items()} TODO_STATUS_MAP_INV = {v: k for k, v in TODO_STATUS_MAP.items()}
def _convert_todo_item(item: TodoItem) -> dict[str, str]: def _convert_todo_item(item: TodoItem) -> dict[str, str | None]:
"""Convert TodoItem dataclass items to dictionary of attributes the tasks API.""" """Convert TodoItem dataclass items to dictionary of attributes the tasks API."""
result: dict[str, str] = {} result: dict[str, str | None] = {}
if item.summary is not None: result["title"] = item.summary
result["title"] = item.summary
if item.status is not None: if item.status is not None:
result["status"] = TODO_STATUS_MAP_INV[item.status] result["status"] = TODO_STATUS_MAP_INV[item.status]
else:
result["status"] = TodoItemStatus.NEEDS_ACTION
if (due := item.due) is not None: if (due := item.due) is not None:
# due API field is a timestamp string, but with only date resolution # due API field is a timestamp string, but with only date resolution
result["due"] = dt_util.start_of_local_day(due).isoformat() result["due"] = dt_util.start_of_local_day(due).isoformat()
if (description := item.description) is not None: else:
result["notes"] = description result["due"] = None
result["notes"] = item.description
return result return result

View File

@ -1,13 +1,9 @@
"""A Local To-do todo platform.""" """A Local To-do todo platform."""
from collections.abc import Iterable
import dataclasses
import logging import logging
from typing import Any
from ical.calendar import Calendar from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream from ical.calendar_stream import IcsCalendarStream
from ical.exceptions import CalendarParseError
from ical.store import TodoStore from ical.store import TodoStore
from ical.todo import Todo, TodoStatus from ical.todo import Todo, TodoStatus
@ -59,26 +55,18 @@ async def async_setup_entry(
async_add_entities([entity], True) async_add_entities([entity], True)
def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]:
"""Convert TodoItem dataclass items to dictionary of attributes for ical consumption."""
result: dict[str, str] = {}
for name, value in obj:
if value is None:
continue
if name == "status":
result[name] = ICS_TODO_STATUS_MAP_INV[value]
else:
result[name] = value
return result
def _convert_item(item: TodoItem) -> Todo: def _convert_item(item: TodoItem) -> Todo:
"""Convert a HomeAssistant TodoItem to an ical Todo.""" """Convert a HomeAssistant TodoItem to an ical Todo."""
try: todo = Todo()
return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory)) if item.uid:
except CalendarParseError as err: todo.uid = item.uid
_LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err) if item.summary:
raise HomeAssistantError("Error parsing todo input fields") from err todo.summary = item.summary
if item.status:
todo.status = ICS_TODO_STATUS_MAP_INV[item.status]
todo.due = item.due
todo.description = item.description
return todo
class LocalTodoListEntity(TodoListEntity): class LocalTodoListEntity(TodoListEntity):

View File

@ -1,6 +1,6 @@
"""A shopping list todo platform.""" """A shopping list todo platform."""
from typing import Any, cast from typing import cast
from homeassistant.components.todo import ( from homeassistant.components.todo import (
TodoItem, TodoItem,
@ -55,11 +55,10 @@ class ShoppingTodoListEntity(TodoListEntity):
async def async_update_todo_item(self, item: TodoItem) -> None: async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update an item to the To-do list.""" """Update an item to the To-do list."""
data: dict[str, Any] = {} data = {
if item.summary: "name": item.summary,
data["name"] = item.summary "complete": item.status == TodoItemStatus.COMPLETED,
if item.status: }
data["complete"] = item.status == TodoItemStatus.COMPLETED
try: try:
await self._data.async_update(item.uid, data) await self._data.async_update(item.uid, data)
except NoMatchingShoppingListItem as err: except NoMatchingShoppingListItem as err:

View File

@ -74,19 +74,19 @@ class TodoItemFieldDescription:
TODO_ITEM_FIELDS = [ TODO_ITEM_FIELDS = [
TodoItemFieldDescription( TodoItemFieldDescription(
service_field=ATTR_DUE_DATE, service_field=ATTR_DUE_DATE,
validation=cv.date, validation=vol.Any(cv.date, None),
todo_item_field=ATTR_DUE, todo_item_field=ATTR_DUE,
required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM,
), ),
TodoItemFieldDescription( TodoItemFieldDescription(
service_field=ATTR_DUE_DATETIME, service_field=ATTR_DUE_DATETIME,
validation=vol.All(cv.datetime, dt_util.as_local), validation=vol.Any(vol.All(cv.datetime, dt_util.as_local), None),
todo_item_field=ATTR_DUE, todo_item_field=ATTR_DUE,
required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM,
), ),
TodoItemFieldDescription( TodoItemFieldDescription(
service_field=ATTR_DESCRIPTION, service_field=ATTR_DESCRIPTION,
validation=cv.string, validation=vol.Any(cv.string, None),
todo_item_field=ATTR_DESCRIPTION, todo_item_field=ATTR_DESCRIPTION,
required_feature=TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, required_feature=TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM,
), ),
@ -485,18 +485,22 @@ async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) ->
_validate_supported_features(entity.supported_features, call.data) _validate_supported_features(entity.supported_features, call.data)
await entity.async_update_todo_item( # Perform a partial update on the existing entity based on the fields
item=TodoItem( # present in the update. This allows explicitly clearing any of the
uid=found.uid, # extended fields present and set to None.
summary=call.data.get("rename"), updated_data = dataclasses.asdict(found)
status=call.data.get("status"), if summary := call.data.get("rename"):
**{ updated_data["summary"] = summary
desc.todo_item_field: call.data[desc.service_field] if status := call.data.get("status"):
for desc in TODO_ITEM_FIELDS updated_data["status"] = status
if desc.service_field in call.data updated_data.update(
}, {
) desc.todo_item_field: call.data[desc.service_field]
for desc in TODO_ITEM_FIELDS
if desc.service_field in call.data
}
) )
await entity.async_update_todo_item(item=TodoItem(**updated_data))
async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None:

View File

@ -34,19 +34,20 @@ async def async_setup_entry(
def _task_api_data(item: TodoItem) -> dict[str, Any]: def _task_api_data(item: TodoItem) -> dict[str, Any]:
"""Convert a TodoItem to the set of add or update arguments.""" """Convert a TodoItem to the set of add or update arguments."""
item_data: dict[str, Any] = {} item_data: dict[str, Any] = {
if summary := item.summary: "content": item.summary,
item_data["content"] = summary # Description needs to be empty string to be cleared
"description": item.description or "",
}
if due := item.due: if due := item.due:
if isinstance(due, datetime.datetime): if isinstance(due, datetime.datetime):
item_data["due"] = { item_data["due_datetime"] = due.isoformat()
"date": due.date().isoformat(),
"datetime": due.isoformat(),
}
else: else:
item_data["due"] = {"date": due.isoformat()} item_data["due_date"] = due.isoformat()
if description := item.description: else:
item_data["description"] = description # Special flag "no date" clears the due date/datetime.
# See https://developer.todoist.com/rest/v2/#update-a-task for more.
item_data["due_string"] = "no date"
return item_data return item_data
@ -128,10 +129,16 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit
if update_data := _task_api_data(item): if update_data := _task_api_data(item):
await self.coordinator.api.update_task(task_id=uid, **update_data) await self.coordinator.api.update_task(task_id=uid, **update_data)
if item.status is not None: if item.status is not None:
if item.status == TodoItemStatus.COMPLETED: # Only update status if changed
await self.coordinator.api.close_task(task_id=uid) for existing_item in self._attr_todo_items or ():
else: if existing_item.uid != item.uid:
await self.coordinator.api.reopen_task(task_id=uid) continue
if item.status != existing_item.status:
if item.status == TodoItemStatus.COMPLETED:
await self.coordinator.api.close_task(task_id=uid)
else:
await self.coordinator.api.reopen_task(task_id=uid)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
async def async_delete_todo_items(self, uids: list[str]) -> None: async def async_delete_todo_items(self, uids: list[str]) -> None:

View File

@ -69,6 +69,19 @@ STATUS:NEEDS-ACTION
END:VTODO END:VTODO
END:VCALENDAR""" END:VCALENDAR"""
TODO_ALL_FIELDS = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN
BEGIN:VTODO
UID:2
DTSTAMP:20171125T000000Z
SUMMARY:Cheese
DESCRIPTION:Any kind will do
STATUS:NEEDS-ACTION
DUE:20171126
END:VTODO
END:VCALENDAR"""
@pytest.fixture @pytest.fixture
def platforms() -> list[Platform]: def platforms() -> list[Platform]:
@ -132,6 +145,18 @@ async def mock_add_to_hass(
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
IGNORE_COMPONENTS = ["BEGIN", "END", "DTSTAMP", "PRODID", "UID", "VERSION"]
def compact_ics(ics: str) -> list[str]:
"""Pull out parts of the rfc5545 content useful for assertions in tests."""
return [
line
for line in ics.split("\n")
if line and not any(filter(line.startswith, IGNORE_COMPONENTS))
]
@pytest.mark.parametrize( @pytest.mark.parametrize(
("todos", "expected_state"), ("todos", "expected_state"),
[ [
@ -292,45 +317,148 @@ async def test_add_item_failure(
[ [
( (
{"rename": "Swiss Cheese"}, {"rename": "Swiss Cheese"},
["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], [
"DESCRIPTION:Any kind will do",
"DUE;VALUE=DATE:20171126",
"STATUS:NEEDS-ACTION",
"SUMMARY:Swiss Cheese",
],
"1", "1",
{**RESULT_ITEM, "summary": "Swiss Cheese"}, {
"uid": "2",
"summary": "Swiss Cheese",
"status": "needs_action",
"description": "Any kind will do",
"due": "2017-11-26",
},
), ),
( (
{"status": "needs_action"}, {"status": "needs_action"},
["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], [
"DESCRIPTION:Any kind will do",
"DUE;VALUE=DATE:20171126",
"STATUS:NEEDS-ACTION",
"SUMMARY:Cheese",
],
"1", "1",
RESULT_ITEM, {
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"description": "Any kind will do",
"due": "2017-11-26",
},
), ),
( (
{"status": "completed"}, {"status": "completed"},
["SUMMARY:Cheese", "STATUS:COMPLETED"], [
"DESCRIPTION:Any kind will do",
"DUE;VALUE=DATE:20171126",
"STATUS:COMPLETED",
"SUMMARY:Cheese",
],
"0", "0",
{**RESULT_ITEM, "status": "completed"}, {
"uid": "2",
"summary": "Cheese",
"status": "completed",
"description": "Any kind will do",
"due": "2017-11-26",
},
), ),
( (
{"rename": "Swiss Cheese", "status": "needs_action"}, {"rename": "Swiss Cheese", "status": "needs_action"},
["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], [
"DESCRIPTION:Any kind will do",
"DUE;VALUE=DATE:20171126",
"STATUS:NEEDS-ACTION",
"SUMMARY:Swiss Cheese",
],
"1", "1",
{**RESULT_ITEM, "summary": "Swiss Cheese"}, {
"uid": "2",
"summary": "Swiss Cheese",
"status": "needs_action",
"description": "Any kind will do",
"due": "2017-11-26",
},
), ),
( (
{"due_date": "2023-11-18"}, {"due_date": "2023-11-18"},
["SUMMARY:Cheese", "DUE;VALUE=DATE:20231118"], [
"DESCRIPTION:Any kind will do",
"DUE;VALUE=DATE:20231118",
"STATUS:NEEDS-ACTION",
"SUMMARY:Cheese",
],
"1", "1",
{**RESULT_ITEM, "due": "2023-11-18"}, {
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"description": "Any kind will do",
"due": "2023-11-18",
},
), ),
( (
{"due_datetime": "2023-11-18T08:30:00-06:00"}, {"due_datetime": "2023-11-18T08:30:00-06:00"},
["SUMMARY:Cheese", "DUE;TZID=America/Regina:20231118T083000"], [
"DESCRIPTION:Any kind will do",
"DUE;TZID=America/Regina:20231118T083000",
"STATUS:NEEDS-ACTION",
"SUMMARY:Cheese",
],
"1", "1",
{**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, {
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"description": "Any kind will do",
"due": "2023-11-18T08:30:00-06:00",
},
),
(
{"due_datetime": None},
[
"DESCRIPTION:Any kind will do",
"STATUS:NEEDS-ACTION",
"SUMMARY:Cheese",
],
"1",
{
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"description": "Any kind will do",
},
), ),
( (
{"description": "Make sure to get Swiss"}, {"description": "Make sure to get Swiss"},
["SUMMARY:Cheese", "DESCRIPTION:Make sure to get Swiss"], [
"DESCRIPTION:Make sure to get Swiss",
"DUE;VALUE=DATE:20171126",
"STATUS:NEEDS-ACTION",
"SUMMARY:Cheese",
],
"1", "1",
{**RESULT_ITEM, "description": "Make sure to get Swiss"}, {
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"due": "2017-11-26",
"description": "Make sure to get Swiss",
},
),
(
{"description": None},
["DUE;VALUE=DATE:20171126", "STATUS:NEEDS-ACTION", "SUMMARY:Cheese"],
"1",
{
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"due": "2017-11-26",
},
), ),
], ],
ids=[ ids=[
@ -340,7 +468,9 @@ async def test_add_item_failure(
"rename_status", "rename_status",
"due_date", "due_date",
"due_datetime", "due_datetime",
"clear_due_date",
"description", "description",
"clear_description",
], ],
) )
async def test_update_item( async def test_update_item(
@ -355,7 +485,7 @@ async def test_update_item(
) -> None: ) -> None:
"""Test updating an item on the list.""" """Test updating an item on the list."""
item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") item = Todo(dav_client, None, TODO_ALL_FIELDS, calendar, "2")
calendar.search = MagicMock(return_value=[item]) calendar.search = MagicMock(return_value=[item])
await config_entry.async_setup(hass) await config_entry.async_setup(hass)
@ -381,8 +511,7 @@ async def test_update_item(
assert dav_client.put.call_args assert dav_client.put.call_args
ics = dav_client.put.call_args.args[1] ics = dav_client.put.call_args.args[1]
for expected in expected_ics: assert compact_ics(ics) == expected_ics
assert expected in ics
state = hass.states.get(TEST_ENTITY) state = hass.states.get(TEST_ENTITY)
assert state assert state

View File

@ -6,7 +6,7 @@
) )
# --- # ---
# name: test_create_todo_list_item[description].1 # name: test_create_todo_list_item[description].1
'{"title": "Soda", "status": "needsAction", "notes": "6-pack"}' '{"title": "Soda", "status": "needsAction", "due": null, "notes": "6-pack"}'
# --- # ---
# name: test_create_todo_list_item[due] # name: test_create_todo_list_item[due]
tuple( tuple(
@ -15,7 +15,7 @@
) )
# --- # ---
# name: test_create_todo_list_item[due].1 # name: test_create_todo_list_item[due].1
'{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00"}' '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}'
# --- # ---
# name: test_create_todo_list_item[summary] # name: test_create_todo_list_item[summary]
tuple( tuple(
@ -24,7 +24,7 @@
) )
# --- # ---
# name: test_create_todo_list_item[summary].1 # name: test_create_todo_list_item[summary].1
'{"title": "Soda", "status": "needsAction"}' '{"title": "Soda", "status": "needsAction", "due": null, "notes": null}'
# --- # ---
# name: test_delete_todo_list_item[_handler] # name: test_delete_todo_list_item[_handler]
tuple( tuple(
@ -106,6 +106,24 @@
}), }),
]) ])
# --- # ---
# name: test_partial_update[clear_description]
tuple(
'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json',
'PATCH',
)
# ---
# name: test_partial_update[clear_description].1
'{"title": "Water", "status": "needsAction", "due": null, "notes": null}'
# ---
# name: test_partial_update[clear_due_date]
tuple(
'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json',
'PATCH',
)
# ---
# name: test_partial_update[clear_due_date].1
'{"title": "Water", "status": "needsAction", "due": null, "notes": null}'
# ---
# name: test_partial_update[description] # name: test_partial_update[description]
tuple( tuple(
'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json',
@ -113,7 +131,7 @@
) )
# --- # ---
# name: test_partial_update[description].1 # name: test_partial_update[description].1
'{"notes": "6-pack"}' '{"title": "Water", "status": "needsAction", "due": null, "notes": "At least one gallon"}'
# --- # ---
# name: test_partial_update[due_date] # name: test_partial_update[due_date]
tuple( tuple(
@ -122,7 +140,16 @@
) )
# --- # ---
# name: test_partial_update[due_date].1 # name: test_partial_update[due_date].1
'{"due": "2023-11-18T00:00:00-08:00"}' '{"title": "Water", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}'
# ---
# name: test_partial_update[empty_description]
tuple(
'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json',
'PATCH',
)
# ---
# name: test_partial_update[empty_description].1
'{"title": "Water", "status": "needsAction", "due": null, "notes": ""}'
# --- # ---
# name: test_partial_update[rename] # name: test_partial_update[rename]
tuple( tuple(
@ -131,7 +158,7 @@
) )
# --- # ---
# name: test_partial_update[rename].1 # name: test_partial_update[rename].1
'{"title": "Soda"}' '{"title": "Soda", "status": "needsAction", "due": null, "notes": null}'
# --- # ---
# name: test_partial_update_status[api_responses0] # name: test_partial_update_status[api_responses0]
tuple( tuple(
@ -140,7 +167,7 @@
) )
# --- # ---
# name: test_partial_update_status[api_responses0].1 # name: test_partial_update_status[api_responses0].1
'{"status": "needsAction"}' '{"title": "Water", "status": "needsAction", "due": null, "notes": null}'
# --- # ---
# name: test_update_todo_list_item[api_responses0] # name: test_update_todo_list_item[api_responses0]
tuple( tuple(
@ -149,5 +176,5 @@
) )
# --- # ---
# name: test_update_todo_list_item[api_responses0].1 # name: test_update_todo_list_item[api_responses0].1
'{"title": "Soda", "status": "completed"}' '{"title": "Soda", "status": "completed", "due": null, "notes": null}'
# --- # ---

View File

@ -48,6 +48,7 @@ LIST_TASKS_RESPONSE_WATER = {
"id": "some-task-id", "id": "some-task-id",
"title": "Water", "title": "Water",
"status": "needsAction", "status": "needsAction",
"description": "Any size is ok",
"position": "00000000000000000001", "position": "00000000000000000001",
}, },
], ],
@ -516,9 +517,19 @@ async def test_update_todo_list_item_error(
[ [
(UPDATE_API_RESPONSES, {"rename": "Soda"}), (UPDATE_API_RESPONSES, {"rename": "Soda"}),
(UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}), (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}),
(UPDATE_API_RESPONSES, {"description": "6-pack"}), (UPDATE_API_RESPONSES, {"due_date": None}),
(UPDATE_API_RESPONSES, {"description": "At least one gallon"}),
(UPDATE_API_RESPONSES, {"description": ""}),
(UPDATE_API_RESPONSES, {"description": None}),
], ],
ids=("rename", "due_date", "description"), ids=(
"rename",
"due_date",
"clear_due_date",
"description",
"empty_description",
"clear_description",
),
) )
async def test_partial_update( async def test_partial_update(
hass: HomeAssistant, hass: HomeAssistant,

View File

@ -65,16 +65,27 @@ def set_time_zone(hass: HomeAssistant) -> None:
hass.config.set_time_zone("America/Regina") hass.config.set_time_zone("America/Regina")
EXPECTED_ADD_ITEM = {
"status": "needs_action",
"summary": "replace batteries",
}
@pytest.mark.parametrize( @pytest.mark.parametrize(
("item_data", "expected_item_data"), ("item_data", "expected_item_data"),
[ [
({}, {}), ({}, EXPECTED_ADD_ITEM),
({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), ({"due_date": "2023-11-17"}, {**EXPECTED_ADD_ITEM, "due": "2023-11-17"}),
( (
{"due_datetime": "2023-11-17T11:30:00+00:00"}, {"due_datetime": "2023-11-17T11:30:00+00:00"},
{"due": "2023-11-17T05:30:00-06:00"}, {**EXPECTED_ADD_ITEM, "due": "2023-11-17T05:30:00-06:00"},
), ),
({"description": "Additional detail"}, {"description": "Additional detail"}), (
{"description": "Additional detail"},
{**EXPECTED_ADD_ITEM, "description": "Additional detail"},
),
({"description": ""}, {**EXPECTED_ADD_ITEM, "description": ""}),
({"description": None}, EXPECTED_ADD_ITEM),
], ],
) )
async def test_add_item( async def test_add_item(
@ -101,11 +112,10 @@ async def test_add_item(
items = await ws_get_items() items = await ws_get_items()
assert len(items) == 1 assert len(items) == 1
assert items[0]["summary"] == "replace batteries" item_data = items[0]
assert items[0]["status"] == "needs_action" assert "uid" in item_data
for k, v in expected_item_data.items(): del item_data["uid"]
assert items[0][k] == v assert item_data == expected_item_data
assert "uid" in items[0]
state = hass.states.get(TEST_ENTITY) state = hass.states.get(TEST_ENTITY)
assert state assert state
@ -207,19 +217,29 @@ async def test_bulk_remove(
assert state.state == "0" assert state.state == "0"
EXPECTED_UPDATE_ITEM = {
"status": "needs_action",
"summary": "soda",
}
@pytest.mark.parametrize( @pytest.mark.parametrize(
("item_data", "expected_item_data", "expected_state"), ("item_data", "expected_item_data", "expected_state"),
[ [
({"status": "completed"}, {"status": "completed"}, "0"), ({"status": "completed"}, {**EXPECTED_UPDATE_ITEM, "status": "completed"}, "0"),
({"due_date": "2023-11-17"}, {"due": "2023-11-17"}, "1"), (
{"due_date": "2023-11-17"},
{**EXPECTED_UPDATE_ITEM, "due": "2023-11-17"},
"1",
),
( (
{"due_datetime": "2023-11-17T11:30:00+00:00"}, {"due_datetime": "2023-11-17T11:30:00+00:00"},
{"due": "2023-11-17T05:30:00-06:00"}, {**EXPECTED_UPDATE_ITEM, "due": "2023-11-17T05:30:00-06:00"},
"1", "1",
), ),
( (
{"description": "Additional detail"}, {"description": "Additional detail"},
{"description": "Additional detail"}, {**EXPECTED_UPDATE_ITEM, "description": "Additional detail"},
"1", "1",
), ),
], ],
@ -246,6 +266,7 @@ async def test_update_item(
# Fetch item # Fetch item
items = await ws_get_items() items = await ws_get_items()
assert len(items) == 1 assert len(items) == 1
item = items[0] item = items[0]
assert item["summary"] == "soda" assert item["summary"] == "soda"
assert item["status"] == "needs_action" assert item["status"] == "needs_action"
@ -254,7 +275,7 @@ async def test_update_item(
assert state assert state
assert state.state == "1" assert state.state == "1"
# Mark item completed # Update item
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
"update_item", "update_item",
@ -268,14 +289,130 @@ async def test_update_item(
assert len(items) == 1 assert len(items) == 1
item = items[0] item = items[0]
assert item["summary"] == "soda" assert item["summary"] == "soda"
for k, v in expected_item_data.items(): assert "uid" in item
assert items[0][k] == v del item["uid"]
assert item == expected_item_data
state = hass.states.get(TEST_ENTITY) state = hass.states.get(TEST_ENTITY)
assert state assert state
assert state.state == expected_state assert state.state == expected_state
@pytest.mark.parametrize(
("item_data", "expected_item_data"),
[
(
{"status": "completed"},
{
"summary": "soda",
"status": "completed",
"description": "Additional detail",
"due": "2024-01-01",
},
),
(
{"due_date": "2024-01-02"},
{
"summary": "soda",
"status": "needs_action",
"description": "Additional detail",
"due": "2024-01-02",
},
),
(
{"due_date": None},
{
"summary": "soda",
"status": "needs_action",
"description": "Additional detail",
},
),
(
{"due_datetime": "2024-01-01 10:30:00"},
{
"summary": "soda",
"status": "needs_action",
"description": "Additional detail",
"due": "2024-01-01T10:30:00-06:00",
},
),
(
{"due_datetime": None},
{
"summary": "soda",
"status": "needs_action",
"description": "Additional detail",
},
),
(
{"description": "updated description"},
{
"summary": "soda",
"status": "needs_action",
"due": "2024-01-01",
"description": "updated description",
},
),
(
{"description": None},
{"summary": "soda", "status": "needs_action", "due": "2024-01-01"},
),
],
ids=[
"status",
"due_date",
"clear_due_date",
"due_datetime",
"clear_due_datetime",
"description",
"clear_description",
],
)
async def test_update_existing_field(
hass: HomeAssistant,
setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
item_data: dict[str, Any],
expected_item_data: dict[str, Any],
) -> None:
"""Test updating a todo item."""
# Create new item
await hass.services.async_call(
TODO_DOMAIN,
"add_item",
{"item": "soda", "description": "Additional detail", "due_date": "2024-01-01"},
target={"entity_id": TEST_ENTITY},
blocking=True,
)
# Fetch item
items = await ws_get_items()
assert len(items) == 1
item = items[0]
assert item["summary"] == "soda"
assert item["status"] == "needs_action"
# Perform update
await hass.services.async_call(
TODO_DOMAIN,
"update_item",
{"item": item["uid"], **item_data},
target={"entity_id": TEST_ENTITY},
blocking=True,
)
# Verify item is updated
items = await ws_get_items()
assert len(items) == 1
item = items[0]
assert item["summary"] == "soda"
assert "uid" in item
del item["uid"]
assert item == expected_item_data
async def test_rename( async def test_rename(
hass: HomeAssistant, hass: HomeAssistant,
setup_integration: None, setup_integration: None,

View File

@ -146,15 +146,19 @@ async def create_mock_platform(
return config_entry return config_entry
@pytest.fixture(name="test_entity_items")
def mock_test_entity_items() -> list[TodoItem]:
"""Fixture that creates the items returned by the test entity."""
return [
TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION),
TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED),
]
@pytest.fixture(name="test_entity") @pytest.fixture(name="test_entity")
def mock_test_entity() -> TodoListEntity: def mock_test_entity(test_entity_items: list[TodoItem]) -> TodoListEntity:
"""Fixture that creates a test TodoList entity with mock service calls.""" """Fixture that creates a test TodoList entity with mock service calls."""
entity1 = MockTodoListEntity( entity1 = MockTodoListEntity(test_entity_items)
[
TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION),
TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED),
]
)
entity1.entity_id = "todo.entity1" entity1.entity_id = "todo.entity1"
entity1._attr_supported_features = ( entity1._attr_supported_features = (
TodoListEntityFeature.CREATE_TODO_ITEM TodoListEntityFeature.CREATE_TODO_ITEM
@ -504,7 +508,7 @@ async def test_update_todo_item_service_by_id_status_only(
item = args.kwargs.get("item") item = args.kwargs.get("item")
assert item assert item
assert item.uid == "1" assert item.uid == "1"
assert item.summary is None assert item.summary == "Item #1"
assert item.status == TodoItemStatus.COMPLETED assert item.status == TodoItemStatus.COMPLETED
@ -530,7 +534,7 @@ async def test_update_todo_item_service_by_id_rename(
assert item assert item
assert item.uid == "1" assert item.uid == "1"
assert item.summary == "Updated item" assert item.summary == "Updated item"
assert item.status is None assert item.status == TodoItemStatus.NEEDS_ACTION
async def test_update_todo_item_service_raises( async def test_update_todo_item_service_raises(
@ -607,7 +611,7 @@ async def test_update_todo_item_service_by_summary_only_status(
assert item assert item
assert item.uid == "1" assert item.uid == "1"
assert item.summary == "Something else" assert item.summary == "Something else"
assert item.status is None assert item.status == TodoItemStatus.NEEDS_ACTION
async def test_update_todo_item_service_by_summary_not_found( async def test_update_todo_item_service_by_summary_not_found(
@ -693,20 +697,32 @@ async def test_update_todo_item_field_unsupported(
( (
TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, TodoListEntityFeature.SET_DUE_DATE_ON_ITEM,
{"due_date": "2023-11-13"}, {"due_date": "2023-11-13"},
TodoItem(uid="1", due=datetime.date(2023, 11, 13)), TodoItem(
uid="1",
summary="Item #1",
status=TodoItemStatus.NEEDS_ACTION,
due=datetime.date(2023, 11, 13),
),
), ),
( (
TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM,
{"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, {"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"},
TodoItem( TodoItem(
uid="1", uid="1",
summary="Item #1",
status=TodoItemStatus.NEEDS_ACTION,
due=datetime.datetime(2023, 11, 13, 17, 0, 0, tzinfo=TEST_TIMEZONE), due=datetime.datetime(2023, 11, 13, 17, 0, 0, tzinfo=TEST_TIMEZONE),
), ),
), ),
( (
TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM,
{"description": "Submit revised draft"}, {"description": "Submit revised draft"},
TodoItem(uid="1", description="Submit revised draft"), TodoItem(
uid="1",
summary="Item #1",
status=TodoItemStatus.NEEDS_ACTION,
description="Submit revised draft",
),
), ),
), ),
) )
@ -736,6 +752,96 @@ async def test_update_todo_item_extended_fields(
assert item == expected_update assert item == expected_update
@pytest.mark.parametrize(
("test_entity_items", "update_data", "expected_update"),
(
(
[TodoItem(uid="1", summary="Summary", description="description")],
{"description": "Submit revised draft"},
TodoItem(uid="1", summary="Summary", description="Submit revised draft"),
),
(
[TodoItem(uid="1", summary="Summary", description="description")],
{"description": ""},
TodoItem(uid="1", summary="Summary", description=""),
),
(
[TodoItem(uid="1", summary="Summary", description="description")],
{"description": None},
TodoItem(uid="1", summary="Summary"),
),
(
[TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))],
{"due_date": datetime.date(2024, 1, 2)},
TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 2)),
),
(
[TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))],
{"due_date": None},
TodoItem(uid="1", summary="Summary"),
),
(
[TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))],
{"due_datetime": datetime.datetime(2024, 1, 1, 10, 0, 0)},
TodoItem(
uid="1",
summary="Summary",
due=datetime.datetime(
2024, 1, 1, 10, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="America/Regina")
),
),
),
(
[
TodoItem(
uid="1",
summary="Summary",
due=datetime.datetime(2024, 1, 1, 10, 0, 0),
)
],
{"due_datetime": None},
TodoItem(uid="1", summary="Summary"),
),
),
ids=[
"overwrite_description",
"overwrite_empty_description",
"clear_description",
"overwrite_due_date",
"clear_due_date",
"overwrite_due_date_with_time",
"clear_due_date_time",
],
)
async def test_update_todo_item_extended_fields_overwrite_existing_values(
hass: HomeAssistant,
test_entity: TodoListEntity,
update_data: dict[str, Any],
expected_update: TodoItem,
) -> None:
"""Test updating an item in a To-do list."""
test_entity._attr_supported_features |= (
TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
| TodoListEntityFeature.SET_DUE_DATE_ON_ITEM
| TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM
)
await create_mock_platform(hass, [test_entity])
await hass.services.async_call(
DOMAIN,
"update_item",
{"item": "1", **update_data},
target={"entity_id": "todo.entity1"},
blocking=True,
)
args = test_entity.async_update_todo_item.call_args
assert args
item = args.kwargs.get("item")
assert item == expected_update
async def test_remove_todo_item_service_by_id( async def test_remove_todo_item_service_by_id(
hass: HomeAssistant, hass: HomeAssistant,
test_entity: TodoListEntity, test_entity: TodoListEntity,

View File

@ -80,7 +80,7 @@ async def test_todo_item_state(
[], [],
{}, {},
[make_api_task(id="task-id-1", content="Soda", is_completed=False)], [make_api_task(id="task-id-1", content="Soda", is_completed=False)],
{"content": "Soda"}, {"content": "Soda", "due_string": "no date", "description": ""},
{"uid": "task-id-1", "summary": "Soda", "status": "needs_action"}, {"uid": "task-id-1", "summary": "Soda", "status": "needs_action"},
), ),
( (
@ -94,7 +94,7 @@ async def test_todo_item_state(
due=Due(is_recurring=False, date="2023-11-18", string="today"), due=Due(is_recurring=False, date="2023-11-18", string="today"),
) )
], ],
{"due": {"date": "2023-11-18"}}, {"description": "", "due_date": "2023-11-18"},
{ {
"uid": "task-id-1", "uid": "task-id-1",
"summary": "Soda", "summary": "Soda",
@ -119,7 +119,8 @@ async def test_todo_item_state(
) )
], ],
{ {
"due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, "description": "",
"due_datetime": "2023-11-18T06:30:00-06:00",
}, },
{ {
"uid": "task-id-1", "uid": "task-id-1",
@ -139,7 +140,7 @@ async def test_todo_item_state(
is_completed=False, is_completed=False,
) )
], ],
{"description": "6-pack"}, {"description": "6-pack", "due_string": "no date"},
{ {
"uid": "task-id-1", "uid": "task-id-1",
"summary": "Soda", "summary": "Soda",
@ -264,11 +265,35 @@ async def test_update_todo_item_status(
("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"), ("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"),
[ [
( (
[make_api_task(id="task-id-1", content="Soda", is_completed=False)], [
make_api_task(
id="task-id-1",
content="Soda",
is_completed=False,
description="desc",
)
],
{"rename": "Milk"}, {"rename": "Milk"},
[make_api_task(id="task-id-1", content="Milk", is_completed=False)], [
{"task_id": "task-id-1", "content": "Milk"}, make_api_task(
{"uid": "task-id-1", "summary": "Milk", "status": "needs_action"}, id="task-id-1",
content="Milk",
is_completed=False,
description="desc",
)
],
{
"task_id": "task-id-1",
"content": "Milk",
"description": "desc",
"due_string": "no date",
},
{
"uid": "task-id-1",
"summary": "Milk",
"status": "needs_action",
"description": "desc",
},
), ),
( (
[make_api_task(id="task-id-1", content="Soda", is_completed=False)], [make_api_task(id="task-id-1", content="Soda", is_completed=False)],
@ -281,7 +306,12 @@ async def test_update_todo_item_status(
due=Due(is_recurring=False, date="2023-11-18", string="today"), due=Due(is_recurring=False, date="2023-11-18", string="today"),
) )
], ],
{"task_id": "task-id-1", "due": {"date": "2023-11-18"}}, {
"task_id": "task-id-1",
"content": "Soda",
"due_date": "2023-11-18",
"description": "",
},
{ {
"uid": "task-id-1", "uid": "task-id-1",
"summary": "Soda", "summary": "Soda",
@ -307,7 +337,9 @@ async def test_update_todo_item_status(
], ],
{ {
"task_id": "task-id-1", "task_id": "task-id-1",
"due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, "content": "Soda",
"due_datetime": "2023-11-18T06:30:00-06:00",
"description": "",
}, },
{ {
"uid": "task-id-1", "uid": "task-id-1",
@ -327,7 +359,12 @@ async def test_update_todo_item_status(
is_completed=False, is_completed=False,
) )
], ],
{"task_id": "task-id-1", "description": "6-pack"}, {
"task_id": "task-id-1",
"content": "Soda",
"description": "6-pack",
"due_string": "no date",
},
{ {
"uid": "task-id-1", "uid": "task-id-1",
"summary": "Soda", "summary": "Soda",
@ -335,8 +372,38 @@ async def test_update_todo_item_status(
"description": "6-pack", "description": "6-pack",
}, },
), ),
(
[
make_api_task(
id="task-id-1",
content="Soda",
description="6-pack",
is_completed=False,
)
],
{"description": None},
[
make_api_task(
id="task-id-1",
content="Soda",
is_completed=False,
description="",
)
],
{
"task_id": "task-id-1",
"content": "Soda",
"description": "",
"due_string": "no date",
},
{
"uid": "task-id-1",
"summary": "Soda",
"status": "needs_action",
},
),
], ],
ids=["rename", "due_date", "due_datetime", "description"], ids=["rename", "due_date", "due_datetime", "description", "clear_description"],
) )
async def test_update_todo_items( async def test_update_todo_items(
hass: HomeAssistant, hass: HomeAssistant,