mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 01:11:51 -04:00
Filter cancelled events out of remote_calendar (#180949)
Co-authored-by: 1358 <1583407+1358@users.noreply.github.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user