diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index d711e0bb8103..579b2116a4b7 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, override import caldav -from homeassistant.components.calendar import CalendarEvent, extract_offset +from homeassistant.components.calendar import ( + CalendarEvent, + CalendarEventStatus, + extract_offset, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -23,6 +27,24 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) OFFSET = "!!" +def _get_status(vevent: caldav.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. + """ + if (value := get_attr_value(vevent, "status")) is None: + return None + try: + return CalendarEventStatus(value.lower()) + except ValueError: + _LOGGER.debug("Ignoring unsupported event status %s", value) + return None + + class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): """Class to utilize the calendar dav client object to get next event.""" @@ -86,6 +108,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): if (v := get_attr_value(vevent, "recurrence_id")) is not None else None ), + status=_get_status(vevent), ) ) @@ -194,6 +217,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): if (v := get_attr_value(vevent, "recurrence_id")) is not None else None ), + status=_get_status(vevent), ) return next_event, offset diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 95e2a3bfea75..f609d02e25ce 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -69,6 +69,7 @@ from .const import ( LIST_EVENT_FIELDS, CalendarEntityFeature, CalendarEntityStateAttribute, + CalendarEventStatus, ) # mypy: disallow-any-generics @@ -379,6 +380,7 @@ class CalendarEvent: uid: str | None = None recurrence_id: str | None = None rrule: str | None = None + status: CalendarEventStatus | None = None @property def start_datetime_local(self) -> datetime.datetime: diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index df0c43b0c73a..afdb4dd321cf 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -33,6 +33,22 @@ class CalendarEntityFeature(IntFlag): UPDATE_EVENT = 4 +class CalendarEventStatus(StrEnum): + """Status of a calendar event. + + A subset of the statuses defined by the rfc5545 STATUS property: a calendar + entity does not return cancelled events, so that value is not represented + here. + + An event without a status is not the same as a confirmed event: it means + the calendar did not report one, either because the source does not + support it or because the integration does not read it yet. + """ + + CONFIRMED = "confirmed" + TENTATIVE = "tentative" + + # rfc5545 fields EVENT_UID = "uid" EVENT_START = "dtstart" @@ -43,6 +59,7 @@ EVENT_LOCATION = "location" EVENT_RECURRENCE_ID = "recurrence_id" EVENT_RECURRENCE_RANGE = "recurrence_range" EVENT_RRULE = "rrule" +EVENT_STATUS = "status" # Service call fields EVENT_START_DATE = "start_date" @@ -69,4 +86,5 @@ LIST_EVENT_FIELDS = { EVENT_SUMMARY, EVENT_DESCRIPTION, EVENT_LOCATION, + EVENT_STATUS, } diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 217696dd1388..e9f134c4958f 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -32,6 +32,7 @@ from homeassistant.components.calendar import ( CalendarEntityDescription, CalendarEntityFeature, CalendarEvent, + CalendarEventStatus, extract_offset, is_offset_reached, ) @@ -535,6 +536,11 @@ def _get_calendar_event(event: Event) -> CalendarEvent: end=event.end.value, description=event.description, location=event.location, + # The Google API defaults an omitted status to confirmed, and gcal_sync + # applies that default, so this is never None. It drops cancelled + # events when building the timeline, so only the statuses a calendar + # entity reports reach here, already in lower case. + status=CalendarEventStatus(event.status.value), ) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index cd52e903c016..f6ece70b4648 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -21,6 +21,7 @@ from homeassistant.components.calendar import ( CalendarEntity, CalendarEntityFeature, CalendarEvent, + CalendarEventStatus, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -228,6 +229,23 @@ def _parse_event(event: dict[str, Any]) -> Event: raise vol.Invalid("Error parsing event input fields") from err +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. + """ + if event.status is None: + return None + try: + return CalendarEventStatus(event.status.value.lower()) + except ValueError: + return None + + def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" start: datetime | date @@ -252,4 +270,5 @@ def _get_calendar_event(event: Event) -> CalendarEvent: rrule=event.rrule.as_rrule_str() if event.rrule else None, recurrence_id=event.recurrence_id, location=event.location, + status=_get_status(event), ) diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index cefbffd0a849..2df5e5ea5310 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -381,6 +381,44 @@ def _mock_calendar(name: str, supported_components: list[str] | None = None) -> return calendar +async def _get_api_events_for_vevent( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + vevent: str, + uid: str, +) -> list[dict[str, Any]]: + """Set up a calendar holding a single VEVENT and return its events from the API. + + Used by tests that assert on how one specific VEVENT property is parsed, + which the shared EVENTS series cannot express: it is fixed at 18 entries + that other tests count on. + """ + calendar = Mock() + calendar.name = "Example" + calendar.get_supported_components = MagicMock(return_value=["VEVENT"]) + calendar.search = MagicMock( + return_value=[Event(None, "0.ics", vevent, calendar, uid)] + ) + + with patch( + "homeassistant.components.caldav.calendar.caldav.DAVClient" + ) as mock_client: + mock_client.return_value.principal.return_value.calendars.return_value = [ + calendar + ] + assert await async_setup_component( + hass, "calendar", {"calendar": CALDAV_CONFIG} + ) + await hass.async_block_till_done() + + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start=2017-11-27&end=2017-11-28" + ) + assert response.status == HTTPStatus.OK + return await response.json() + + @pytest.fixture(name="config") def mock_config() -> dict[str, Any]: """Fixture to provide calendar configuration.yaml.""" @@ -1078,6 +1116,7 @@ async def test_get_events_custom_calendars( "uid": "0", "recurrence_id": None, "rrule": None, + "status": None, } ] @@ -1101,41 +1140,57 @@ LOCATION:Hamburg DESCRIPTION:This occurrence was moved END:VEVENT END:VCALENDAR""" - calendar = Mock() - calendar.name = "Example" - calendar.get_supported_components = MagicMock(return_value=["VEVENT"]) - calendar.search = MagicMock( - return_value=[ - Event( - None, "0.ics", vevent_with_recurrence_id, calendar, "original-event-uid" - ) - ] + events = await _get_api_events_for_vevent( + hass, hass_client, vevent_with_recurrence_id, "original-event-uid" ) - with patch( - "homeassistant.components.caldav.calendar.caldav.DAVClient" - ) as mock_client: - mock_client.return_value.principal.return_value.calendars.return_value = [ - calendar - ] - assert await async_setup_component( - hass, "calendar", {"calendar": CALDAV_CONFIG} - ) - await hass.async_block_till_done() - - client = await hass_client() - response = await client.get( - f"/api/calendars/{TEST_ENTITY}?start=2017-11-27&end=2017-11-28" - ) - assert response.status == HTTPStatus.OK - events = await response.json() - assert len(events) == 1 assert events[0]["uid"] == "original-event-uid" assert events[0]["recurrence_id"] == "2017-11-27 17:00:00+00:00" assert events[0]["summary"] == "Modified occurrence" +ICS_WITH_STATUS = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:status-event-uid +DTSTAMP:20171125T000000Z +DTSTART:20171127T170000Z +DTEND:20171127T180000Z +SUMMARY:This is an event with a status +LOCATION:Hamburg +DESCRIPTION:Surprisingly rainy +STATUS:{status} +END:VEVENT +END:VCALENDAR""" + + +@pytest.mark.parametrize( + ("status", "expected_status"), + [ + 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"), + ], +) +async def test_get_events_with_status( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + status: str, + expected_status: str | None, +) -> None: + """Test that the rfc5545 STATUS property is populated from VEVENT data.""" + events = await _get_api_events_for_vevent( + hass, hass_client, ICS_WITH_STATUS.format(status=status), "status-event-uid" + ) + + assert len(events) == 1 + assert events[0]["status"] == expected_status + + @pytest.mark.parametrize( ("calendars"), [ diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index e6d40f0de3c8..be53dd772f18 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1556,6 +1556,7 @@ async def test_working_location_get_events( "summary": "Home", "description": "test event", "location": "Test Cases", + "status": "confirmed", }, { "start": "2026-08-25", @@ -1563,6 +1564,7 @@ async def test_working_location_get_events( "summary": "Office", "description": "test event", "location": "Test Cases", + "status": "confirmed", }, { "start": "2026-08-31", @@ -1570,6 +1572,7 @@ async def test_working_location_get_events( "summary": "Home", "description": "test event", "location": "Test Cases", + "status": "confirmed", }, ] } @@ -1652,6 +1655,7 @@ async def test_working_location_ignore_availability_false( "summary": "Home", "description": "test event", "location": "Test Cases", + "status": "confirmed", } ] } @@ -1778,3 +1782,37 @@ async def test_calendar_background_color( entity = entity_registry.async_get("calendar.test_calendar") assert entity is not None assert entity.options.get("calendar", {}).get("color") == expected_color + + +@pytest.mark.freeze_time("2022-03-27 12:05:00+00:00") +@pytest.mark.parametrize( + ("event_status", "expected_status"), + [ + pytest.param({"status": "tentative"}, "tentative", id="tentative"), + pytest.param({"status": "confirmed"}, "confirmed", id="confirmed"), + # The Google API documents confirmed as the default for an omitted + # status and gcal_sync applies it, so it is never reported as unset. + pytest.param({}, "confirmed", id="defaults_to_confirmed"), + ], + # Cancelled is not covered: in the Google API it means deleted rather than + # called off, and gcal_sync drops those events when building the timeline, + # so they never reach the integration. +) +async def test_http_api_event_status( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_events_list_items: ApiResult, + component_setup: ComponentSetup, + event_status: dict[str, str], + expected_status: str, +) -> None: + """Test that the event status is returned by the API.""" + mock_events_list_items([{**TEST_EVENT, **upcoming(), **event_status}]) + assert await component_setup() + + client = await hass_client() + response = await client.get(upcoming_event_url()) + assert response.status == HTTPStatus.OK + events = await response.json() + assert len(events) == 1 + assert events[0]["status"] == expected_status diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index 68d93d7fb7f7..0a6c9bf7f3db 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -28,6 +28,7 @@ 'start': dict({ 'date': '2024-09-21', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -42,6 +43,7 @@ 'start': dict({ 'date': '2024-09-21', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -56,6 +58,7 @@ 'start': dict({ 'date': '2024-09-22', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -70,6 +73,7 @@ 'start': dict({ 'date': '2024-09-22', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -84,6 +88,7 @@ 'start': dict({ 'date': '2024-09-22', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -98,6 +103,7 @@ 'start': dict({ 'date': '2024-09-22', }), + 'status': None, 'summary': 'Arbeite an einem kreativen Projekt', 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', }), @@ -112,6 +118,7 @@ 'start': dict({ 'date': '2024-09-23', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -126,6 +133,7 @@ 'start': dict({ 'date': '2024-09-23', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -140,6 +148,7 @@ 'start': dict({ 'date': '2024-09-24', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -154,6 +163,7 @@ 'start': dict({ 'date': '2024-09-24', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -168,6 +178,7 @@ 'start': dict({ 'date': '2024-09-25', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -182,6 +193,7 @@ 'start': dict({ 'date': '2024-09-25', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -196,6 +208,7 @@ 'start': dict({ 'date': '2024-09-25', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -210,6 +223,7 @@ 'start': dict({ 'date': '2024-09-26', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -224,6 +238,7 @@ 'start': dict({ 'date': '2024-09-26', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -238,6 +253,7 @@ 'start': dict({ 'date': '2024-09-27', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -252,6 +268,7 @@ 'start': dict({ 'date': '2024-09-27', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -266,6 +283,7 @@ 'start': dict({ 'date': '2024-09-28', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -280,6 +298,7 @@ 'start': dict({ 'date': '2024-09-28', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -294,6 +313,7 @@ 'start': dict({ 'date': '2024-09-28', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -308,6 +328,7 @@ 'start': dict({ 'date': '2024-09-29', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -322,6 +343,7 @@ 'start': dict({ 'date': '2024-09-29', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -336,6 +358,7 @@ 'start': dict({ 'date': '2024-09-29', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -350,6 +373,7 @@ 'start': dict({ 'date': '2024-09-30', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -364,6 +388,7 @@ 'start': dict({ 'date': '2024-09-30', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -378,6 +403,7 @@ 'start': dict({ 'date': '2024-10-01', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -392,6 +418,7 @@ 'start': dict({ 'date': '2024-10-01', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -406,6 +433,7 @@ 'start': dict({ 'date': '2024-10-02', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -420,6 +448,7 @@ 'start': dict({ 'date': '2024-10-02', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -434,6 +463,7 @@ 'start': dict({ 'date': '2024-10-02', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -448,6 +478,7 @@ 'start': dict({ 'date': '2024-10-03', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -462,6 +493,7 @@ 'start': dict({ 'date': '2024-10-03', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -476,6 +508,7 @@ 'start': dict({ 'date': '2024-10-04', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -490,6 +523,7 @@ 'start': dict({ 'date': '2024-10-04', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -504,6 +538,7 @@ 'start': dict({ 'date': '2024-10-05', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -518,6 +553,7 @@ 'start': dict({ 'date': '2024-10-05', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -532,6 +568,7 @@ 'start': dict({ 'date': '2024-10-05', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -546,6 +583,7 @@ 'start': dict({ 'date': '2024-10-06', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -560,6 +598,7 @@ 'start': dict({ 'date': '2024-10-06', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -574,6 +613,7 @@ 'start': dict({ 'date': '2024-10-06', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -588,6 +628,7 @@ 'start': dict({ 'date': '2024-10-06', }), + 'status': None, 'summary': 'Monatliche Finanzübersicht erstellen', 'uid': '369afeed-61e3-4bf7-9747-66e05807134c', }), @@ -602,6 +643,7 @@ 'start': dict({ 'date': '2024-10-07', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -616,6 +658,7 @@ 'start': dict({ 'date': '2024-10-07', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -634,6 +677,7 @@ 'start': dict({ 'dateTime': '2024-09-21T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -648,6 +692,7 @@ 'start': dict({ 'dateTime': '2024-09-22T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -662,6 +707,7 @@ 'start': dict({ 'dateTime': '2024-09-23T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -676,6 +722,7 @@ 'start': dict({ 'dateTime': '2024-09-24T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -690,6 +737,7 @@ 'start': dict({ 'dateTime': '2024-09-25T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -704,6 +752,7 @@ 'start': dict({ 'dateTime': '2024-09-26T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -718,6 +767,7 @@ 'start': dict({ 'dateTime': '2024-09-27T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -732,6 +782,7 @@ 'start': dict({ 'dateTime': '2024-09-28T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -746,6 +797,7 @@ 'start': dict({ 'dateTime': '2024-09-29T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -760,6 +812,7 @@ 'start': dict({ 'dateTime': '2024-09-30T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -774,6 +827,7 @@ 'start': dict({ 'dateTime': '2024-10-01T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -788,6 +842,7 @@ 'start': dict({ 'dateTime': '2024-10-02T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -802,6 +857,7 @@ 'start': dict({ 'dateTime': '2024-10-03T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -816,6 +872,7 @@ 'start': dict({ 'dateTime': '2024-10-04T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -830,6 +887,7 @@ 'start': dict({ 'dateTime': '2024-10-05T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -844,6 +902,7 @@ 'start': dict({ 'dateTime': '2024-10-06T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -858,6 +917,7 @@ 'start': dict({ 'dateTime': '2024-10-07T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -876,6 +936,7 @@ 'start': dict({ 'dateTime': '2024-09-22T02:00:00+02:00', }), + 'status': None, 'summary': 'Rechnungen bezahlen', 'uid': '2f6fcabc-f670-4ec3-ba65-817e8deea490_91c09432-10ac-4a49-bd20-823081ec29ed', }), @@ -894,6 +955,7 @@ 'start': dict({ 'date': '2024-08-31', }), + 'status': None, 'summary': 'Rechnungen bezahlen', 'uid': '2f6fcabc-f670-4ec3-ba65-817e8deea490', }), @@ -908,6 +970,7 @@ 'start': dict({ 'date': '2024-09-21', }), + 'status': None, 'summary': 'Wochenendausflug planen', 'uid': '86ea2475-d1b5-4020-bdcc-c188c7996afa', }), @@ -922,6 +985,7 @@ 'start': dict({ 'date': '2024-09-27', }), + 'status': None, 'summary': 'Buch zu Ende lesen', 'uid': '88de7cd9-af2b-49ce-9afd-bf941d87336b', }), diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 0cf1bc138cd6..a1ba41e4bfc1 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -1216,3 +1216,41 @@ async def test_adjacent_events_stay_on( state = hass.states.get(TEST_ENTITY) assert state.state == STATE_ON assert state.attributes["message"] == "Second" + + +ICS_WITH_STATUS = """BEGIN:VCALENDAR +BEGIN:VEVENT +SUMMARY:Bastille Day Party +DTSTART:19970714 +DTEND:19970715 +STATUS:{status} +END:VEVENT +END:VCALENDAR +""" + + +@pytest.mark.parametrize( + ("ics_content", "expected_status"), + [ + pytest.param( + ICS_WITH_STATUS.format(status="TENTATIVE"), "tentative", id="tentative" + ), + 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") +async def test_event_status( + get_events: GetEventsFn, + expected_status: str | None, +) -> None: + """Test that the rfc5545 STATUS property is returned by the API.""" + events = await get_events("1997-07-13T00:00:00", "1997-07-16T00:00:00") + assert len(events) == 1 + assert events[0]["status"] == expected_status diff --git a/tests/components/mealie/snapshots/test_calendar.ambr b/tests/components/mealie/snapshots/test_calendar.ambr index fc252c48ecca..7589b663d0e2 100644 --- a/tests/components/mealie/snapshots/test_calendar.ambr +++ b/tests/components/mealie/snapshots/test_calendar.ambr @@ -44,6 +44,7 @@ 'start': dict({ 'date': '2024-01-22', }), + 'status': None, 'summary': 'Zoete aardappel curry traybake', 'uid': None, }), @@ -58,6 +59,7 @@ 'start': dict({ 'date': '2024-01-23', }), + 'status': None, 'summary': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', 'uid': None, }), @@ -72,6 +74,7 @@ 'start': dict({ 'date': '2024-01-23', }), + 'status': None, 'summary': 'Pampered Chef Double Chocolate Mocha Trifle', 'uid': None, }), @@ -86,6 +89,7 @@ 'start': dict({ 'date': '2024-01-22', }), + 'status': None, 'summary': 'Cheeseburger Sliders (Easy, 30-min Recipe)', 'uid': None, }), @@ -100,6 +104,7 @@ 'start': dict({ 'date': '2024-01-23', }), + 'status': None, 'summary': 'All-American Beef Stew Recipe', 'uid': None, }), @@ -114,6 +119,7 @@ 'start': dict({ 'date': '2024-01-23', }), + 'status': None, 'summary': 'Miso Udon Noodles with Spinach and Tofu', 'uid': None, }), @@ -128,6 +134,7 @@ 'start': dict({ 'date': '2024-01-21', }), + 'status': None, 'summary': 'Aquavite', 'uid': None, }), diff --git a/tests/components/remote_calendar/snapshots/test_calendar.ambr b/tests/components/remote_calendar/snapshots/test_calendar.ambr index e372be5255c0..470e3d08d221 100644 --- a/tests/components/remote_calendar/snapshots/test_calendar.ambr +++ b/tests/components/remote_calendar/snapshots/test_calendar.ambr @@ -12,6 +12,7 @@ 'start': dict({ 'dateTime': '2024-04-26T14:00:00-06:00', }), + 'status': None, 'summary': 'Uffe', 'uid': '040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000010000000309AE93C8C3A94489F90ADBEA30C2F2B', }), diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 50b7665121f9..1a1d3c20776d 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -78,6 +78,7 @@ def get_events_response(start: dict[str, str], end: dict[str, str]) -> dict[str, "uid": None, "recurrence_id": None, "rrule": None, + "status": None, } diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index b3df44bdac2d..e6ef47a0343f 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -20,6 +20,7 @@ 'start': dict({ 'date': '2022-01-06', }), + 'status': None, 'summary': 'Christmas tree pickup', 'uid': None, }), diff --git a/tests/components/withings/snapshots/test_calendar.ambr b/tests/components/withings/snapshots/test_calendar.ambr index 045b4216a2f0..ef0877d28953 100644 --- a/tests/components/withings/snapshots/test_calendar.ambr +++ b/tests/components/withings/snapshots/test_calendar.ambr @@ -20,6 +20,7 @@ 'start': dict({ 'dateTime': '2023-08-29T12:06:51-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -34,6 +35,7 @@ 'start': dict({ 'dateTime': '2023-08-31T01:08:27-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -48,6 +50,7 @@ 'start': dict({ 'dateTime': '2023-08-04T09:00:39-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -62,6 +65,7 @@ 'start': dict({ 'dateTime': '2023-09-22T16:33:55-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -76,6 +80,7 @@ 'start': dict({ 'dateTime': '2023-09-14T11:20:49-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -90,6 +95,7 @@ 'start': dict({ 'dateTime': '2023-09-22T16:55:53-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -104,6 +110,7 @@ 'start': dict({ 'dateTime': '2023-09-14T10:42:31-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -118,6 +125,7 @@ 'start': dict({ 'dateTime': '2023-10-09T00:12:49-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -132,6 +140,7 @@ 'start': dict({ 'dateTime': '2023-10-09T02:39:43-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -146,6 +155,7 @@ 'start': dict({ 'dateTime': '2023-10-09T02:13:23-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -160,6 +170,7 @@ 'start': dict({ 'dateTime': '2023-10-09T02:13:23-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }),