Filter cancelled events out of local_calendar (#180948)

Co-authored-by: 1358 <1583407+1358@users.noreply.github.com>
This commit is contained in:
Tom Mittelstädt
2026-10-03 18:44:17 -07:00
committed by GitHub
co-authored by 1358
parent b8954e0103
commit 0f3081ebd0
2 changed files with 113 additions and 13 deletions
@@ -7,7 +7,7 @@ from typing import Any, override
from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream
from ical.event import Event
from ical.event import Event, EventStatus
from ical.exceptions import CalendarParseError
from ical.store import EventStore, EventStoreError
from ical.timeline import Timeline, materialize_timeline
@@ -92,7 +92,11 @@ class LocalCalendarEntity(CalendarEntity):
"""Return the next upcoming event."""
if self._timeline is None:
return None
events = self._timeline.active_after(dt_util.now())
events = (
event
for event in self._timeline.active_after(dt_util.now())
if not _is_cancelled(event)
)
if event := next(events, None):
return _get_calendar_event(event)
return None
@@ -108,7 +112,11 @@ class LocalCalendarEntity(CalendarEntity):
start_date,
end_date,
)
return [_get_calendar_event(event) for event in events]
return [
_get_calendar_event(event)
for event in events
if not _is_cancelled(event)
]
return await self.hass.async_add_executor_job(events_in_range)
@@ -229,14 +237,22 @@ def _parse_event(event: dict[str, Any]) -> Event:
raise probatio.Invalid("Error parsing event input fields") from err
def _is_cancelled(event: Event) -> bool:
"""Return whether an event has been called off.
rfc5545 keeps a cancelled event in the calendar rather than deleting it,
and an imported calendar can carry one. A calendar entity does not return
such events.
"""
return event.status == EventStatus.CANCELLED
def _get_status(event: Event) -> CalendarEventStatus | None:
"""Return the status of an event, if a calendar entity reports that status.
ical models the full rfc5545 set, which includes cancelled, and an imported
calendar can contain such an event. A calendar entity does not report a
cancelled status, so anything outside the supported set maps to no status.
ical's enum is a plain (str, Enum) rather than a StrEnum, so its value has
to be read explicitly.
Cancelled events are filtered out before this, so they never reach here.
ical's enum is a plain (str, Enum) rather than a StrEnum, so its value has to
be read explicitly.
"""
if event.status is None:
return None
@@ -1238,11 +1238,6 @@ END:VCALENDAR
pytest.param(
ICS_WITH_STATUS.format(status="CONFIRMED"), "confirmed", id="confirmed"
),
pytest.param(
ICS_WITH_STATUS.format(status="CANCELLED"),
None,
id="cancelled_is_not_reported",
),
],
)
@pytest.mark.usefixtures("setup_integration")
@@ -1254,3 +1249,92 @@ async def test_event_status(
events = await get_events("1997-07-13T00:00:00", "1997-07-16T00:00:00")
assert len(events) == 1
assert events[0]["status"] == expected_status
@pytest.mark.parametrize("ics_content", [ICS_WITH_STATUS.format(status="CANCELLED")])
@pytest.mark.usefixtures("setup_integration")
async def test_cancelled_event_is_not_returned(get_events: GetEventsFn) -> None:
"""Test that an event called off is not returned by the API."""
events = await get_events("1997-07-13T00:00:00", "1997-07-16T00:00:00")
assert events == []
ONGOING_CANCELLED_ICS = """BEGIN:VCALENDAR
PRODID:-//homeassistant.io//local_calendar 1.0//EN
VERSION:2.0
BEGIN:VEVENT
DTSTART:20260729T014500
DTEND:20260729T021500
SUMMARY:Called off
UID:called-off
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR
"""
@pytest.mark.parametrize("ics_content", [ONGOING_CANCELLED_ICS])
async def test_cancelled_event_does_not_turn_the_entity_on(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
config_entry: MockConfigEntry,
) -> None:
"""Test that an event called off is not picked up as the current event.
The event would be ongoing at this time were it not cancelled, so this
covers the state path rather than the API one.
"""
freezer.move_to("2026-07-29 07:50:20+00:00") # 01:50:20 in America/Regina
config_entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == STATE_OFF
CANCELLED_OCCURRENCE_ICS = """BEGIN:VCALENDAR
PRODID:-//homeassistant.io//local_calendar 1.0//EN
VERSION:2.0
BEGIN:VEVENT
DTSTART;VALUE=DATE:20261002
DTEND;VALUE=DATE:20261003
RRULE:FREQ=DAILY;COUNT=5
SUMMARY:Daily series
UID:daily-series
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20261003
DTEND;VALUE=DATE:20261004
RECURRENCE-ID;VALUE=DATE:20261003
SUMMARY:Daily series
UID:daily-series
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR
"""
@pytest.mark.parametrize("ics_content", [CANCELLED_OCCURRENCE_ICS])
@pytest.mark.usefixtures("setup_integration")
async def test_cancelled_occurrence_of_a_series_is_not_returned(
get_events: GetEventsFn,
) -> None:
"""Test that only the cancelled occurrence of a recurring series is dropped.
The filtering has to happen after the series is expanded: dropping the
cancelled VEVENT before expansion would remove the override, and the RRULE
would then produce that day as an ordinary event again.
"""
events = await get_events("2026-10-01T00:00:00", "2026-10-08T00:00:00")
assert [event["start"] for event in events] == [
{"date": "2026-10-02"},
{"date": "2026-10-04"},
{"date": "2026-10-05"},
{"date": "2026-10-06"},
]