Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion homeassistant/components/caldav/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@

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
Expand All @@ -23,6 +27,24 @@
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."""

Expand Down Expand Up @@ -86,6 +108,7 @@ def _get_events(
if (v := get_attr_value(vevent, "recurrence_id")) is not None
else None
),
status=_get_status(vevent),
)
)

Expand Down Expand Up @@ -194,6 +217,7 @@ def _get_next_event(
if (v := get_attr_value(vevent, "recurrence_id")) is not None
else None
),
status=_get_status(vevent),
)
return next_event, offset

Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/calendar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
LIST_EVENT_FIELDS,
CalendarEntityFeature,
CalendarEntityStateAttribute,
CalendarEventStatus,
)

# mypy: disallow-any-generics
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions homeassistant/components/calendar/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -43,6 +59,7 @@ class CalendarEntityFeature(IntFlag):
EVENT_RECURRENCE_ID = "recurrence_id"
EVENT_RECURRENCE_RANGE = "recurrence_range"
EVENT_RRULE = "rrule"
EVENT_STATUS = "status"

# Service call fields
EVENT_START_DATE = "start_date"
Expand All @@ -69,4 +86,5 @@ class CalendarEntityFeature(IntFlag):
EVENT_SUMMARY,
EVENT_DESCRIPTION,
EVENT_LOCATION,
EVENT_STATUS,
}
6 changes: 6 additions & 0 deletions homeassistant/components/google/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
CalendarEntityDescription,
CalendarEntityFeature,
CalendarEvent,
CalendarEventStatus,
extract_offset,
is_offset_reached,
)
Expand Down Expand Up @@ -533,6 +534,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),
)


Expand Down
19 changes: 19 additions & 0 deletions homeassistant/components/local_calendar/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
CalendarEntity,
CalendarEntityFeature,
CalendarEvent,
CalendarEventStatus,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
Expand Down Expand Up @@ -214,6 +215,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
Expand All @@ -238,4 +256,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),
)
109 changes: 82 additions & 27 deletions tests/components/caldav/test_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -1078,6 +1116,7 @@ async def test_get_events_custom_calendars(
"uid": "0",
"recurrence_id": None,
"rrule": None,
"status": None,
}
]

Expand All @@ -1101,41 +1140,57 @@ async def test_get_events_with_recurrence_id(
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"),
[
Expand Down
34 changes: 34 additions & 0 deletions tests/components/google/test_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -1604,3 +1604,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,
component_setup,
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
Loading
Loading