Fix local todo list persistence for due dates (#110830)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/110844/head
Allen Porter 2024-02-18 03:59:50 -08:00 committed by GitHub
parent 9ac199a8f2
commit babb436512
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 152 additions and 18 deletions

View File

@ -1,5 +1,6 @@
"""A Local To-do todo platform."""
import datetime
import logging
from ical.calendar import Calendar
@ -24,7 +25,8 @@ from .store import LocalTodoListStore
_LOGGER = logging.getLogger(__name__)
PRODID = "-//homeassistant.io//local_todo 1.0//EN"
PRODID = "-//homeassistant.io//local_todo 2.0//EN"
PRODID_REQUIRES_MIGRATION = "-//homeassistant.io//local_todo 1.0//EN"
ICS_TODO_STATUS_MAP = {
TodoStatus.IN_PROCESS: TodoItemStatus.NEEDS_ACTION,
@ -38,6 +40,25 @@ ICS_TODO_STATUS_MAP_INV = {
}
def _migrate_calendar(calendar: Calendar) -> bool:
"""Upgrade due dates to rfc5545 format.
In rfc5545 due dates are exclusive, however we previously set the due date
as inclusive based on what the user set in the UI. A task is considered
overdue at midnight at the start of a date so we need to shift the due date
to the next day for old calendar versions.
"""
if calendar.prodid is None or calendar.prodid != PRODID_REQUIRES_MIGRATION:
return False
migrated = False
for todo in calendar.todos:
if todo.due is None or isinstance(todo.due, datetime.datetime):
continue
todo.due += datetime.timedelta(days=1)
migrated = True
return migrated
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@ -48,12 +69,16 @@ async def async_setup_entry(
store = hass.data[DOMAIN][config_entry.entry_id]
ics = await store.async_load()
calendar = IcsCalendarStream.calendar_from_ics(ics)
migrated = _migrate_calendar(calendar)
calendar.prodid = PRODID
name = config_entry.data[CONF_TODO_LIST_NAME]
entity = LocalTodoListEntity(store, calendar, name, unique_id=config_entry.entry_id)
async_add_entities([entity], True)
if migrated:
await entity.async_save()
def _convert_item(item: TodoItem) -> Todo:
"""Convert a HomeAssistant TodoItem to an ical Todo."""
@ -65,6 +90,8 @@ def _convert_item(item: TodoItem) -> Todo:
if item.status:
todo.status = ICS_TODO_STATUS_MAP_INV[item.status]
todo.due = item.due
if todo.due and not isinstance(todo.due, datetime.datetime):
todo.due += datetime.timedelta(days=1)
todo.description = item.description
return todo
@ -99,31 +126,36 @@ class LocalTodoListEntity(TodoListEntity):
async def async_update(self) -> None:
"""Update entity state based on the local To-do items."""
self._attr_todo_items = [
TodoItem(
uid=item.uid,
summary=item.summary or "",
status=ICS_TODO_STATUS_MAP.get(
item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION
),
due=item.due,
description=item.description,
todo_items = []
for item in self._calendar.todos:
if (due := item.due) and not isinstance(due, datetime.datetime):
due -= datetime.timedelta(days=1)
todo_items.append(
TodoItem(
uid=item.uid,
summary=item.summary or "",
status=ICS_TODO_STATUS_MAP.get(
item.status or TodoStatus.NEEDS_ACTION,
TodoItemStatus.NEEDS_ACTION,
),
due=due,
description=item.description,
)
)
for item in self._calendar.todos
]
self._attr_todo_items = todo_items
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Add an item to the To-do list."""
todo = _convert_item(item)
TodoStore(self._calendar).add(todo)
await self._async_save()
await self.async_save()
await self.async_update_ha_state(force_refresh=True)
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update an item to the To-do list."""
todo = _convert_item(item)
TodoStore(self._calendar).edit(todo.uid, todo)
await self._async_save()
await self.async_save()
await self.async_update_ha_state(force_refresh=True)
async def async_delete_todo_items(self, uids: list[str]) -> None:
@ -131,7 +163,7 @@ class LocalTodoListEntity(TodoListEntity):
store = TodoStore(self._calendar)
for uid in uids:
store.delete(uid)
await self._async_save()
await self.async_save()
await self.async_update_ha_state(force_refresh=True)
async def async_move_todo_item(
@ -156,10 +188,10 @@ class LocalTodoListEntity(TodoListEntity):
if dst_idx > src_idx:
dst_idx -= 1
todos.insert(dst_idx, src_item)
await self._async_save()
await self.async_save()
await self.async_update_ha_state(force_refresh=True)
async def _async_save(self) -> None:
async def async_save(self) -> None:
"""Persist the todo list to disk."""
content = IcsCalendarStream.calendar_to_ics(self._calendar)
await self._store.async_store(content)

View File

@ -0,0 +1,47 @@
# serializer version: 1
# name: test_parse_existing_ics[completed]
list([
dict({
'status': 'completed',
'summary': 'Complete Task',
'uid': '077cb7f2-6c89-11ee-b2a9-0242ac110002',
}),
])
# ---
# name: test_parse_existing_ics[due]
list([
dict({
'due': '2023-10-23',
'status': 'needs_action',
'summary': 'Task',
'uid': '077cb7f2-6c89-11ee-b2a9-0242ac110002',
}),
])
# ---
# name: test_parse_existing_ics[empty]
list([
])
# ---
# name: test_parse_existing_ics[migrate_legacy_due]
list([
dict({
'due': '2023-10-23',
'status': 'needs_action',
'summary': 'Task',
'uid': '077cb7f2-6c89-11ee-b2a9-0242ac110002',
}),
])
# ---
# name: test_parse_existing_ics[needs_action]
list([
dict({
'status': 'needs_action',
'summary': 'Incomplete Task',
'uid': '077cb7f2-6c89-11ee-b2a9-0242ac110002',
}),
])
# ---
# name: test_parse_existing_ics[not_exists]
list([
])
# ---

View File

@ -5,6 +5,7 @@ import textwrap
from typing import Any
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
from homeassistant.core import HomeAssistant
@ -628,13 +629,64 @@ async def test_move_item_previous_unknown(
),
"1",
),
(
textwrap.dedent(
"""\
BEGIN:VCALENDAR
PRODID:-//homeassistant.io//local_todo 1.0//EN
VERSION:2.0
BEGIN:VTODO
DTSTAMP:20231024T014011
UID:077cb7f2-6c89-11ee-b2a9-0242ac110002
CREATED:20231017T010348
LAST-MODIFIED:20231024T014011
SEQUENCE:1
STATUS:NEEDS-ACTION
SUMMARY:Task
DUE:20231023
END:VTODO
END:VCALENDAR
"""
),
"1",
),
(
textwrap.dedent(
"""\
BEGIN:VCALENDAR
PRODID:-//homeassistant.io//local_todo 2.0//EN
VERSION:2.0
BEGIN:VTODO
DTSTAMP:20231024T014011
UID:077cb7f2-6c89-11ee-b2a9-0242ac110002
CREATED:20231017T010348
LAST-MODIFIED:20231024T014011
SEQUENCE:1
STATUS:NEEDS-ACTION
SUMMARY:Task
DUE:20231024
END:VTODO
END:VCALENDAR
"""
),
"1",
),
],
ids=("empty", "not_exists", "completed", "needs_action"),
ids=(
"empty",
"not_exists",
"completed",
"needs_action",
"migrate_legacy_due",
"due",
),
)
async def test_parse_existing_ics(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
snapshot: SnapshotAssertion,
expected_state: str,
) -> None:
"""Test parsing ics content."""
@ -643,6 +695,9 @@ async def test_parse_existing_ics(
assert state
assert state.state == expected_state
items = await ws_get_items()
assert items == snapshot
async def test_susbcribe(
hass: HomeAssistant,