Filter cancelled events out of caldav (#180947)

This commit is contained in:
Tom Mittelstädt
2026-10-02 11:58:45 +02:00
committed by GitHub
parent bbfbdee65e
commit 277d212ac6
2 changed files with 65 additions and 6 deletions
+17 -5
View File
@@ -31,11 +31,9 @@ OFFSET = "!!"
def _get_status(vevent: CalendarObjectResource) -> CalendarEventStatus | None:
"""Return the rfc5545 STATUS of a VEVENT, if a calendar entity reports it.
Anything outside the supported set is dropped rather than passed on, which
covers both the cancelled status a calendar entity does not report and the
iana-tokens and x-names that rfc5545 also permits here: reporting no status
at all is closer to the truth than reporting one the consumer cannot
interpret.
Cancelled events never reach this, they are filtered out before an event is
built. Anything else outside the supported set is dropped: rfc5545 also
permits iana-tokens and x-names here, which a consumer cannot interpret.
"""
if (value := get_attr_value(vevent, "status")) is None:
return None
@@ -46,6 +44,17 @@ def _get_status(vevent: CalendarObjectResource) -> CalendarEventStatus | None:
return None
def _is_cancelled(vevent: CalendarObjectResource) -> bool:
"""Return whether a VEVENT has been called off.
rfc5545 keeps a cancelled event in the calendar rather than deleting it, so
a calendar has to be read to find out. A calendar entity does not return
such events.
"""
value = get_attr_value(vevent, "status")
return value is not None and value.lower() == "cancelled"
class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
"""Class to utilize the calendar dav client object to get next event."""
@@ -101,6 +110,8 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
vevent = event.vobject_instance.vevent
if not self.is_matching(vevent, self.search):
continue
if _is_cancelled(vevent):
continue
event_list.append(
CalendarEvent(
summary=get_attr_value(vevent, "summary") or "",
@@ -196,6 +207,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
self.is_matching(vevent, self.search)
and (not self.is_all_day(vevent) or self.include_all_day)
and not self.is_over(vevent)
and not _is_cancelled(vevent)
)
),
None,
+48 -1
View File
@@ -1174,7 +1174,6 @@ END:VCALENDAR"""
pytest.param("TENTATIVE", "tentative", id="tentative"),
pytest.param("CONFIRMED", "confirmed", id="confirmed"),
pytest.param("Tentative", "tentative", id="mixed_case"),
pytest.param("CANCELLED", None, id="cancelled_is_not_reported"),
pytest.param("X-VENDOR-SPECIFIC", None, id="unsupported_value"),
],
)
@@ -1193,6 +1192,54 @@ async def test_get_events_with_status(
assert events[0]["status"] == expected_status
def _mock_calendar_holding(name: str, vevents: list[str]) -> Mock:
"""Return a mock calendar holding exactly the given VEVENTs."""
calendar = _mock_calendar(name)
calendar.search = MagicMock(
return_value=[
Event(None, f"{idx}.ics", vevent, calendar, str(idx))
for idx, vevent in enumerate(vevents)
]
)
return calendar
async def test_cancelled_event_is_not_returned(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
) -> None:
"""Test that an event called off is not returned by the API."""
events = await _get_api_events_for_vevent(
hass,
hass_client,
ICS_WITH_STATUS.format(status="CANCELLED"),
"status-event-uid",
)
assert events == []
@pytest.mark.parametrize("tz", [UTC])
@pytest.mark.parametrize(
("calendars"),
[[_mock_calendar_holding("Example", [ICS_WITH_STATUS.format(status="CANCELLED")])]],
)
@pytest.mark.freeze_time(_local_datetime(17, 30))
async def test_cancelled_event_is_not_the_ongoing_event(
hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]]
) -> None:
"""Test that an event called off does not turn the entity on.
The event would be ongoing at this time were it not cancelled, so this
covers the state path rather than the API one.
"""
await setup_platform_cb()
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == STATE_OFF
@pytest.mark.parametrize(
("calendars"),
[