Fix local todo list persistence for due dates (#110830)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>pull/110844/head
parent
9ac199a8f2
commit
babb436512
|
@ -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)
|
||||
|
|
|
@ -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([
|
||||
])
|
||||
# ---
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue