Filter cancelled events out of remote_calendar (#180949)

Co-authored-by: 1358 <1583407+1358@users.noreply.github.com>
This commit is contained in:
Tom Mittelstädt
2026-09-02 07:32:26 +02:00
committed by GitHub
co-authored by 1358
parent 553d46dbcf
commit 1a0baa3f36
2 changed files with 127 additions and 3 deletions
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
import logging
from typing import override
from ical.event import Event
from ical.event import Event, EventStatus
from ical.timeline import Timeline, materialize_timeline
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
@@ -67,7 +67,11 @@ class RemoteCalendarEntity(
if self._timeline is None:
return None
now = dt_util.now()
events = self._timeline.active_after(now)
events = (
event
for event in self._timeline.active_after(now)
if not _is_cancelled(event)
)
if event := next(events, None):
return _get_calendar_event(event)
return None
@@ -84,7 +88,11 @@ class RemoteCalendarEntity(
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)
@@ -132,6 +140,16 @@ class RemoteCalendarEntity(
self.async_write_ha_state()
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, so
a remote calendar can serve one. A calendar entity does not return such
events.
"""
return event.status == EventStatus.CANCELLED
def _get_calendar_event(event: Event) -> CalendarEvent:
"""Return a CalendarEvent from an API event."""
@@ -213,6 +213,112 @@ async def test_api_date_event(
assert len(events) == 1
CANCELLED_EVENT_ICS = textwrap.dedent(
"""\
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Festival International de Jazz de Montreal
LOCATION:Montreal
DTSTART:20070628
DTEND:20070709
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR
"""
)
@respx.mock
async def test_cancelled_event_is_not_returned(
hass: HomeAssistant,
config_entry: MockConfigEntry,
get_events: GetEventsFn,
) -> None:
"""Test that an event called off is not returned by the API."""
respx.get(CALENDER_URL).mock(
return_value=Response(status_code=200, text=CANCELLED_EVENT_ICS)
)
await setup_integration(hass, config_entry)
events = await get_events("2007-06-28T00:00:00Z", "2007-07-10T00:00:00Z")
assert events == []
@pytest.mark.freeze_time(datetime(2007, 6, 28, 12))
@respx.mock
async def test_cancelled_event_does_not_turn_the_entity_on(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test that an event called off is not picked up as the current event.
The event would be active at this time were it not cancelled, so this
covers the state path rather than the API one.
"""
respx.get(CALENDER_URL).mock(
return_value=Response(status_code=200, text=CANCELLED_EVENT_ICS)
)
await setup_integration(hass, config_entry)
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == STATE_OFF
CANCELLED_OCCURRENCE_ICS = textwrap.dedent(
"""\
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Daily series
UID:daily-series
DTSTART;VALUE=DATE:20261002
DTEND;VALUE=DATE:20261003
RRULE:FREQ=DAILY;COUNT=5
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
SUMMARY:Daily series
UID:daily-series
RECURRENCE-ID;VALUE=DATE:20261003
DTSTART;VALUE=DATE:20261003
DTEND;VALUE=DATE:20261004
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR
"""
)
@respx.mock
async def test_cancelled_occurrence_of_a_series_is_not_returned(
hass: HomeAssistant,
config_entry: MockConfigEntry,
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.
"""
respx.get(CALENDER_URL).mock(
return_value=Response(status_code=200, text=CANCELLED_OCCURRENCE_ICS)
)
await setup_integration(hass, config_entry)
events = await get_events("2026-10-01T00:00:00Z", "2026-10-08T00:00:00Z")
assert [event["start"] for event in events] == [
{"date": "2026-10-02"},
{"date": "2026-10-04"},
{"date": "2026-10-05"},
{"date": "2026-10-06"},
]
@pytest.mark.freeze_time(datetime(2007, 6, 28, 12))
@respx.mock
async def test_active_event(