Add due date and description to Google Tasks (#104654)

* Add tests for config validation function

* Add Google Tasks due date and description

* Revert test timezone

* Update changes after upstream

* Update homeassistant/components/google_tasks/todo.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Add google tasks tests for creating

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2023-11-29 13:37:43 -08:00 committed by Franck Nijhof
parent 55bafc260d
commit 655b067277
No known key found for this signature in database
GPG key ID: D62583BA8AB11CA3
3 changed files with 114 additions and 68 deletions

View file

@ -1,7 +1,7 @@
"""Google Tasks todo platform.""" """Google Tasks todo platform."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import date, datetime, timedelta
from typing import Any, cast from typing import Any, cast
from homeassistant.components.todo import ( from homeassistant.components.todo import (
@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .api import AsyncConfigEntryAuth from .api import AsyncConfigEntryAuth
from .const import DOMAIN from .const import DOMAIN
@ -35,9 +36,31 @@ def _convert_todo_item(item: TodoItem) -> dict[str, str]:
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]
if (due := item.due) is not None:
# due API field is a timestamp string, but with only date resolution
result["due"] = dt_util.start_of_local_day(due).isoformat()
if (description := item.description) is not None:
result["notes"] = description
return result return result
def _convert_api_item(item: dict[str, str]) -> TodoItem:
"""Convert tasks API items into a TodoItem."""
due: date | None = None
if (due_str := item.get("due")) is not None:
due = datetime.fromisoformat(due_str).date()
return TodoItem(
summary=item["title"],
uid=item["id"],
status=TODO_STATUS_MAP.get(
item.get("status", ""),
TodoItemStatus.NEEDS_ACTION,
),
due=due,
description=item.get("notes"),
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
@ -68,6 +91,8 @@ class GoogleTaskTodoListEntity(
TodoListEntityFeature.CREATE_TODO_ITEM TodoListEntityFeature.CREATE_TODO_ITEM
| TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM
| TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM
| TodoListEntityFeature.SET_DUE_DATE_ON_ITEM
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
) )
def __init__( def __init__(
@ -88,17 +113,7 @@ class GoogleTaskTodoListEntity(
"""Get the current set of To-do items.""" """Get the current set of To-do items."""
if self.coordinator.data is None: if self.coordinator.data is None:
return None return None
return [ return [_convert_api_item(item) for item in _order_tasks(self.coordinator.data)]
TodoItem(
summary=item["title"],
uid=item["id"],
status=TODO_STATUS_MAP.get(
item.get("status"), # type: ignore[arg-type]
TodoItemStatus.NEEDS_ACTION,
),
)
for item in _order_tasks(self.coordinator.data)
]
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."""

View file

@ -1,11 +1,29 @@
# serializer version: 1 # serializer version: 1
# name: test_create_todo_list_item[api_responses0] # name: test_create_todo_list_item[description]
tuple( tuple(
'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json',
'POST', 'POST',
) )
# --- # ---
# name: test_create_todo_list_item[api_responses0].1 # name: test_create_todo_list_item[description].1
'{"title": "Soda", "status": "needsAction", "notes": "6-pack"}'
# ---
# name: test_create_todo_list_item[due]
tuple(
'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json',
'POST',
)
# ---
# name: test_create_todo_list_item[due].1
'{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00"}'
# ---
# name: test_create_todo_list_item[summary]
tuple(
'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json',
'POST',
)
# ---
# name: test_create_todo_list_item[summary].1
'{"title": "Soda", "status": "needsAction"}' '{"title": "Soda", "status": "needsAction"}'
# --- # ---
# name: test_delete_todo_list_item[_handler] # name: test_delete_todo_list_item[_handler]
@ -38,6 +56,33 @@
}), }),
]) ])
# --- # ---
# name: test_partial_update[description]
tuple(
'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json',
'PATCH',
)
# ---
# name: test_partial_update[description].1
'{"notes": "6-pack"}'
# ---
# name: test_partial_update[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[due_date].1
'{"due": "2023-11-18T00:00:00-08:00"}'
# ---
# name: test_partial_update[rename]
tuple(
'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json',
'PATCH',
)
# ---
# name: test_partial_update[rename].1
'{"title": "Soda"}'
# ---
# name: test_partial_update_status[api_responses0] # name: test_partial_update_status[api_responses0]
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',
@ -47,15 +92,6 @@
# name: test_partial_update_status[api_responses0].1 # name: test_partial_update_status[api_responses0].1
'{"status": "needsAction"}' '{"status": "needsAction"}'
# --- # ---
# name: test_partial_update_title[api_responses0]
tuple(
'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json',
'PATCH',
)
# ---
# name: test_partial_update_title[api_responses0].1
'{"title": "Soda"}'
# ---
# name: test_update_todo_list_item[api_responses0] # name: test_update_todo_list_item[api_responses0]
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',

View file

@ -19,13 +19,12 @@ from homeassistant.exceptions import HomeAssistantError
from tests.typing import WebSocketGenerator from tests.typing import WebSocketGenerator
ENTITY_ID = "todo.my_tasks" ENTITY_ID = "todo.my_tasks"
ITEM = {
"id": "task-list-id-1",
"title": "My tasks",
}
LIST_TASK_LIST_RESPONSE = { LIST_TASK_LIST_RESPONSE = {
"items": [ "items": [ITEM],
{
"id": "task-list-id-1",
"title": "My tasks",
},
]
} }
EMPTY_RESPONSE = {} EMPTY_RESPONSE = {}
LIST_TASKS_RESPONSE = { LIST_TASKS_RESPONSE = {
@ -76,6 +75,20 @@ LIST_TASKS_RESPONSE_MULTIPLE = {
], ],
} }
# API responses when testing update methods
UPDATE_API_RESPONSES = [
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE_WATER,
EMPTY_RESPONSE, # update
LIST_TASKS_RESPONSE, # refresh after update
]
CREATE_API_RESPONSES = [
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE,
EMPTY_RESPONSE, # create
LIST_TASKS_RESPONSE, # refresh
]
@pytest.fixture @pytest.fixture
def platforms() -> list[str]: def platforms() -> list[str]:
@ -207,12 +220,14 @@ def mock_http_response(response_handler: list | Callable) -> Mock:
"title": "Task 1", "title": "Task 1",
"status": "needsAction", "status": "needsAction",
"position": "0000000000000001", "position": "0000000000000001",
"due": "2023-11-18T00:00:00+00:00",
}, },
{ {
"id": "task-2", "id": "task-2",
"title": "Task 2", "title": "Task 2",
"status": "completed", "status": "completed",
"position": "0000000000000002", "position": "0000000000000002",
"notes": "long description",
}, },
], ],
}, },
@ -238,11 +253,13 @@ async def test_get_items(
"uid": "task-1", "uid": "task-1",
"summary": "Task 1", "summary": "Task 1",
"status": "needs_action", "status": "needs_action",
"due": "2023-11-18",
}, },
{ {
"uid": "task-2", "uid": "task-2",
"summary": "Task 2", "summary": "Task 2",
"status": "completed", "status": "completed",
"description": "long description",
}, },
] ]
@ -333,21 +350,20 @@ async def test_task_items_error_response(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"api_responses", ("api_responses", "item_data"),
[ [
[ (CREATE_API_RESPONSES, {}),
LIST_TASK_LIST_RESPONSE, (CREATE_API_RESPONSES, {"due_date": "2023-11-18"}),
LIST_TASKS_RESPONSE, (CREATE_API_RESPONSES, {"description": "6-pack"}),
EMPTY_RESPONSE, # create
LIST_TASKS_RESPONSE, # refresh after delete
]
], ],
ids=["summary", "due", "description"],
) )
async def test_create_todo_list_item( async def test_create_todo_list_item(
hass: HomeAssistant, hass: HomeAssistant,
setup_credentials: None, setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[], Awaitable[bool]],
mock_http_response: Mock, mock_http_response: Mock,
item_data: dict[str, Any],
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test for creating a To-do Item.""" """Test for creating a To-do Item."""
@ -361,7 +377,7 @@ async def test_create_todo_list_item(
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
"add_item", "add_item",
{"item": "Soda"}, {"item": "Soda", **item_data},
target={"entity_id": "todo.my_tasks"}, target={"entity_id": "todo.my_tasks"},
blocking=True, blocking=True,
) )
@ -407,17 +423,7 @@ async def test_create_todo_list_item_error(
) )
@pytest.mark.parametrize( @pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES])
"api_responses",
[
[
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE_WATER,
EMPTY_RESPONSE, # update
LIST_TASKS_RESPONSE, # refresh after update
]
],
)
async def test_update_todo_list_item( async def test_update_todo_list_item(
hass: HomeAssistant, hass: HomeAssistant,
setup_credentials: None, setup_credentials: None,
@ -483,21 +489,20 @@ async def test_update_todo_list_item_error(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"api_responses", ("api_responses", "item_data"),
[ [
[ (UPDATE_API_RESPONSES, {"rename": "Soda"}),
LIST_TASK_LIST_RESPONSE, (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}),
LIST_TASKS_RESPONSE_WATER, (UPDATE_API_RESPONSES, {"description": "6-pack"}),
EMPTY_RESPONSE, # update
LIST_TASKS_RESPONSE, # refresh after update
]
], ],
ids=("rename", "due_date", "description"),
) )
async def test_partial_update_title( async def test_partial_update(
hass: HomeAssistant, hass: HomeAssistant,
setup_credentials: None, setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]], integration_setup: Callable[[], Awaitable[bool]],
mock_http_response: Any, mock_http_response: Any,
item_data: dict[str, Any],
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test for partial update with title only.""" """Test for partial update with title only."""
@ -511,7 +516,7 @@ async def test_partial_update_title(
await hass.services.async_call( await hass.services.async_call(
TODO_DOMAIN, TODO_DOMAIN,
"update_item", "update_item",
{"item": "some-task-id", "rename": "Soda"}, {"item": "some-task-id", **item_data},
target={"entity_id": "todo.my_tasks"}, target={"entity_id": "todo.my_tasks"},
blocking=True, blocking=True,
) )
@ -522,17 +527,7 @@ async def test_partial_update_title(
assert call.kwargs.get("body") == snapshot assert call.kwargs.get("body") == snapshot
@pytest.mark.parametrize( @pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES])
"api_responses",
[
[
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE_WATER,
EMPTY_RESPONSE, # update
LIST_TASKS_RESPONSE, # refresh after update
]
],
)
async def test_partial_update_status( async def test_partial_update_status(
hass: HomeAssistant, hass: HomeAssistant,
setup_credentials: None, setup_credentials: None,