From 1a0baa3f36e8dd6992f19172a6b267300c7f6be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Mittelst=C3=A4dt?= <1358-github@monster-box.de> Date: Wed, 2 Sep 2026 07:32:26 +0200 Subject: [PATCH] Filter cancelled events out of remote_calendar (#180949) Co-authored-by: 1358 <1583407+1358@users.noreply.github.com> --- .../components/remote_calendar/calendar.py | 24 +++- .../remote_calendar/test_calendar.py | 106 ++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 7273bf345e8d..30cdd57e303b 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -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.""" diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py index 2cc9b8adac18..954b43e67b7c 100644 --- a/tests/components/remote_calendar/test_calendar.py +++ b/tests/components/remote_calendar/test_calendar.py @@ -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(