Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Only show Google Tasks that are parents and fix ordering #103820

Merged
merged 3 commits into from Nov 24, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
35 changes: 33 additions & 2 deletions homeassistant/components/google_tasks/todo.py
@@ -1,8 +1,9 @@
"""Google Tasks todo platform."""
from __future__ import annotations

from collections.abc import Iterator
from datetime import timedelta
from typing import cast
from typing import Any, cast

from homeassistant.components.todo import (
TodoItem,
Expand Down Expand Up @@ -96,7 +97,7 @@ def todo_items(self) -> list[TodoItem] | None:
item.get("status"), TodoItemStatus.NEEDS_ACTION # type: ignore[arg-type]
),
)
for item in self.coordinator.data
for item in _order_tasks(self.coordinator.data)
]

async def async_create_todo_item(self, item: TodoItem) -> None:
Expand All @@ -121,3 +122,33 @@ async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete To-do items."""
await self.coordinator.api.delete(self._task_list_id, uids)
await self.coordinator.async_refresh()


def _order_tasks(tasks: list[dict[str, Any]]) -> Iterator[dict[str, Any]]:
"""Order the task items response.

Home Assistant To-do items do not support the Google Task parent/sibbling relationships
so we preserve them as a pre-order traversal where children come after
their parents. All tasks have an order amongst their sibblings based on
position.
"""
# Build a dict of parent task id to child tasks, a tree with "" as the root.
# The siblings at each level are sorted by position.
children: dict[str, list[dict[str, Any]]] = {}
for task in tasks:
parent = task.get("parent", "")
if child_list := children.get(parent):
child_list.append(task)
else:
children[parent] = [task]
for subtasks in children.values():
subtasks.sort(key=lambda task: task["position"])

# Pre-order traversal of the root tasks down to their children. Anytime
# child tasks are found, they are inserted at the front of the queue.
queue = [*children.get("", ())]
while queue and (task := queue.pop(0)):
yield task

if child_tasks := children.get(task["id"]):
queue = [*child_tasks, *queue]
34 changes: 34 additions & 0 deletions tests/components/google_tasks/snapshots/test_todo.ambr
Expand Up @@ -14,6 +14,40 @@
'POST',
)
# ---
# name: test_parent_child_ordering[api_responses0]
list([
dict({
'status': 'needs_action',
'summary': 'Task 1',
'uid': 'task-1',
}),
dict({
'status': 'needs_action',
'summary': 'Task 2',
'uid': 'task-2',
}),
dict({
'status': 'needs_action',
'summary': 'Task 3 (Parent)',
'uid': 'task-3',
}),
dict({
'status': 'needs_action',
'summary': 'Child 1',
'uid': 'task-3-1',
}),
dict({
'status': 'needs_action',
'summary': 'Child 2',
'uid': 'task-3-2',
}),
dict({
'status': 'needs_action',
'summary': 'Task 4',
'uid': 'task-4',
}),
])
# ---
# name: test_partial_update_status[api_responses0]
tuple(
'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json',
Expand Down
109 changes: 102 additions & 7 deletions tests/components/google_tasks/test_todo.py
Expand Up @@ -45,14 +45,34 @@

LIST_TASKS_RESPONSE_WATER = {
"items": [
{"id": "some-task-id", "title": "Water", "status": "needsAction"},
{
"id": "some-task-id",
"title": "Water",
"status": "needsAction",
"position": "00000000000000000001",
},
],
}
LIST_TASKS_RESPONSE_MULTIPLE = {
"items": [
{"id": "some-task-id-1", "title": "Water", "status": "needsAction"},
{"id": "some-task-id-2", "title": "Milk", "status": "needsAction"},
{"id": "some-task-id-3", "title": "Cheese", "status": "needsAction"},
{
"id": "some-task-id-2",
"title": "Milk",
"status": "needsAction",
"position": "00000000000000000002",
},
{
"id": "some-task-id-1",
"title": "Water",
"status": "needsAction",
"position": "00000000000000000001",
},
{
"id": "some-task-id-3",
"title": "Cheese",
"status": "needsAction",
"position": "00000000000000000003",
},
],
}

Expand Down Expand Up @@ -199,8 +219,18 @@ def mock_http_response(response_handler: list | Callable) -> Mock:
LIST_TASK_LIST_RESPONSE,
{
"items": [
{"id": "task-1", "title": "Task 1", "status": "needsAction"},
{"id": "task-2", "title": "Task 2", "status": "completed"},
{
"id": "task-1",
"title": "Task 1",
"status": "needsAction",
"position": "0000000000000001",
},
{
"id": "task-2",
"title": "Task 2",
"status": "completed",
"position": "0000000000000002",
},
],
},
]
Expand Down Expand Up @@ -558,7 +588,7 @@ async def test_partial_update_status(
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE_MULTIPLE,
[EMPTY_RESPONSE, EMPTY_RESPONSE, EMPTY_RESPONSE], # Delete batch
LIST_TASKS_RESPONSE, # refresh after create
LIST_TASKS_RESPONSE, # refresh after delete
]
)
)
Expand Down Expand Up @@ -714,3 +744,68 @@ async def test_delete_server_error(
target={"entity_id": "todo.my_tasks"},
blocking=True,
)


@pytest.mark.parametrize(
"api_responses",
[
[
LIST_TASK_LIST_RESPONSE,
{
"items": [
{
"id": "task-3-2",
"title": "Child 2",
"status": "needsAction",
"parent": "task-3",
"position": "0000000000000002",
},
{
"id": "task-3-1",
"title": "Child 1",
"status": "needsAction",
"parent": "task-3",
"position": "0000000000000001",
},
{
"id": "task-3",
"title": "Task 3 (Parent)",
"status": "needsAction",
"position": "0000000000000003",
},
{
"id": "task-2",
"title": "Task 2",
"status": "needsAction",
"position": "0000000000000002",
},
{
"id": "task-1",
"title": "Task 1",
"status": "needsAction",
"position": "0000000000000001",
},
{
"id": "task-4",
"title": "Task 4",
"status": "needsAction",
"position": "0000000000000004",
},
],
},
]
],
)
async def test_parent_child_ordering(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
snapshot: SnapshotAssertion,
) -> None:
"""Test getting todo list items."""

assert await integration_setup()

items = await ws_get_items()
assert items == snapshot