mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 22:38:02 -04:00
Filter cancelled events out of local_calendar (#180948)
Co-authored-by: 1358 <1583407+1358@users.noreply.github.com>
This commit is contained in:
@@ -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"},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user