diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index c89b36ce636..01c8d4fd5ed 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -74,6 +74,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, handle_calendar_event_create) websocket_api.async_register_command(hass, handle_calendar_event_delete) + websocket_api.async_register_command(hass, handle_calendar_event_update) await component.async_setup(config) return True @@ -297,6 +298,16 @@ class CalendarEntity(Entity): """Delete an event on the calendar.""" raise NotImplementedError() + async def async_update_event( + self, + uid: str, + event: dict[str, Any], + recurrence_id: str | None = None, + recurrence_range: str | None = None, + ) -> None: + """Delete an event on the calendar.""" + raise NotImplementedError() + class CalendarEventView(http.HomeAssistantView): """View to retrieve calendar content.""" @@ -500,3 +511,61 @@ async def handle_calendar_event_delete( connection.send_error(msg["id"], "failed", str(ex)) else: connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "calendar/event/update", + vol.Required("entity_id"): cv.entity_id, + vol.Required(EVENT_UID): cv.string, + vol.Optional(EVENT_RECURRENCE_ID): cv.string, + vol.Optional(EVENT_RECURRENCE_RANGE): cv.string, + vol.Required(CONF_EVENT): vol.Schema( + vol.All( + { + vol.Required(EVENT_START): vol.Any(cv.date, cv.datetime), + vol.Required(EVENT_END): vol.Any(cv.date, cv.datetime), + vol.Required(EVENT_SUMMARY): cv.string, + vol.Optional(EVENT_DESCRIPTION): cv.string, + vol.Optional(EVENT_RRULE): _validate_rrule, + }, + _has_same_type(EVENT_START, EVENT_END), + _has_consistent_timezone(EVENT_START, EVENT_END), + _is_sorted(EVENT_START, EVENT_END), + ) + ), + } +) +@websocket_api.async_response +async def handle_calendar_event_update( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle creation of a calendar event.""" + component: EntityComponent[CalendarEntity] = hass.data[DOMAIN] + if not (entity := component.get_entity(msg["entity_id"])): + connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") + return + + if ( + not entity.supported_features + or not entity.supported_features & CalendarEntityFeature.UPDATE_EVENT + ): + connection.send_message( + websocket_api.error_message( + msg["id"], ERR_NOT_SUPPORTED, "Calendar does not support event update" + ) + ) + return + + try: + await entity.async_update_event( + msg[EVENT_UID], + msg[CONF_EVENT], + recurrence_id=msg.get(EVENT_RECURRENCE_ID), + recurrence_range=msg.get(EVENT_RECURRENCE_RANGE), + ) + except (HomeAssistantError, ValueError) as ex: + _LOGGER.error("Error handling Calendar Event call: %s", ex) + connection.send_error(msg["id"], "failed", str(ex)) + else: + connection.send_result(msg["id"]) diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index adee190200a..4a29a28d71d 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -10,6 +10,7 @@ class CalendarEntityFeature(IntEnum): CREATE_EVENT = 1 DELETE_EVENT = 2 + UPDATE_EVENT = 4 # rfc5545 fields diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 63a2f2b03d2..be6fb4a17b5 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -15,11 +15,7 @@ from pydantic import ValidationError import voluptuous as vol from homeassistant.components.calendar import ( - EVENT_DESCRIPTION, - EVENT_END, EVENT_RRULE, - EVENT_START, - EVENT_SUMMARY, CalendarEntity, CalendarEntityFeature, CalendarEvent, @@ -55,7 +51,9 @@ class LocalCalendarEntity(CalendarEntity): _attr_has_entity_name = True _attr_supported_features = ( - CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.DELETE_EVENT + CalendarEntityFeature.CREATE_EVENT + | CalendarEntityFeature.DELETE_EVENT + | CalendarEntityFeature.UPDATE_EVENT ) def __init__( @@ -104,22 +102,7 @@ class LocalCalendarEntity(CalendarEntity): async def async_create_event(self, **kwargs: Any) -> None: """Add a new event to calendar.""" - event_data = { - EVENT_SUMMARY: kwargs[EVENT_SUMMARY], - EVENT_START: kwargs[EVENT_START], - EVENT_END: kwargs[EVENT_END], - EVENT_DESCRIPTION: kwargs.get(EVENT_DESCRIPTION), - } - try: - event = Event.parse_obj(event_data) - except ValidationError as err: - _LOGGER.debug( - "Error parsing event input fields: %s (%s)", event_data, str(err) - ) - raise vol.Invalid("Error parsing event input fields") from err - if rrule := kwargs.get(EVENT_RRULE): - event.rrule = Recur.from_rrule(rrule) - + event = _parse_event(kwargs) EventStore(self._calendar).add(event) await self._async_store() await self.async_update_ha_state(force_refresh=True) @@ -142,6 +125,38 @@ class LocalCalendarEntity(CalendarEntity): await self._async_store() await self.async_update_ha_state(force_refresh=True) + async def async_update_event( + self, + uid: str, + event: dict[str, Any], + recurrence_id: str | None = None, + recurrence_range: str | None = None, + ) -> None: + """Update an existing event on the calendar.""" + new_event = _parse_event(event) + range_value: Range = Range.NONE + if recurrence_range == Range.THIS_AND_FUTURE: + range_value = Range.THIS_AND_FUTURE + EventStore(self._calendar).edit( + uid, + new_event, + recurrence_id=recurrence_id, + recurrence_range=range_value, + ) + await self._async_store() + await self.async_update_ha_state(force_refresh=True) + + +def _parse_event(event: dict[str, Any]) -> Event: + """Parse an ical event from a home assistant event dictionary.""" + if rrule := event.get(EVENT_RRULE): + event[EVENT_RRULE] = Recur.from_rrule(rrule) + try: + return Event.parse_obj(event) + except ValidationError as err: + _LOGGER.debug("Error parsing event input fields: %s (%s)", event, str(err)) + raise vol.Invalid("Error parsing event input fields") from err + def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 02c7f01b42d..38c17b15b04 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -3,6 +3,8 @@ from datetime import timedelta from http import HTTPStatus from unittest.mock import patch +import pytest + from homeassistant.bootstrap import async_setup_component from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util @@ -67,3 +69,91 @@ async def test_calendars_http_api(hass, hass_client): {"entity_id": "calendar.calendar_1", "name": "Calendar 1"}, {"entity_id": "calendar.calendar_2", "name": "Calendar 2"}, ] + + +@pytest.mark.parametrize( + "payload,code", + [ + ( + { + "type": "calendar/event/create", + "entity_id": "calendar.calendar_1", + "event": { + "summary": "Bastille Day Party", + "dtstart": "1997-07-14T17:00:00+00:00", + "dtend": "1997-07-15T04:00:00+00:00", + }, + }, + "not_supported", + ), + ( + { + "type": "calendar/event/create", + "entity_id": "calendar.calendar_99", + "event": { + "summary": "Bastille Day Party", + "dtstart": "1997-07-14T17:00:00+00:00", + "dtend": "1997-07-15T04:00:00+00:00", + }, + }, + "not_found", + ), + ( + { + "type": "calendar/event/delete", + "entity_id": "calendar.calendar_1", + "uid": "some-uid", + }, + "not_supported", + ), + ( + { + "type": "calendar/event/delete", + "entity_id": "calendar.calendar_99", + "uid": "some-uid", + }, + "not_found", + ), + ( + { + "type": "calendar/event/update", + "entity_id": "calendar.calendar_1", + "uid": "some-uid", + "event": { + "summary": "Bastille Day Party", + "dtstart": "1997-07-14T17:00:00+00:00", + "dtend": "1997-07-15T04:00:00+00:00", + }, + }, + "not_supported", + ), + ( + { + "type": "calendar/event/update", + "entity_id": "calendar.calendar_99", + "uid": "some-uid", + "event": { + "summary": "Bastille Day Party", + "dtstart": "1997-07-14T17:00:00+00:00", + "dtend": "1997-07-15T04:00:00+00:00", + }, + }, + "not_found", + ), + ], +) +async def test_unsupported_websocket(hass, hass_ws_client, payload, code): + """Test unsupported websocket command.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + **payload, + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("error") + assert resp["error"].get("code") == code diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 647e0d979f6..3cbbf6a19ad 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -174,7 +174,7 @@ async def test_empty_calendar( assert state.state == STATE_OFF assert dict(state.attributes) == { "friendly_name": FRIENDLY_NAME, - "supported_features": 3, + "supported_features": 7, } @@ -292,7 +292,7 @@ async def test_active_event( "location": "", "start_time": start.strftime(DATE_STR_FORMAT), "end_time": end.strftime(DATE_STR_FORMAT), - "supported_features": 3, + "supported_features": 7, } @@ -328,7 +328,7 @@ async def test_upcoming_event( "location": "", "start_time": start.strftime(DATE_STR_FORMAT), "end_time": end.strftime(DATE_STR_FORMAT), - "supported_features": 3, + "supported_features": 7, } @@ -521,6 +521,238 @@ async def test_websocket_delete_recurring( ] +async def test_websocket_update( + ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn +): + """Test websocket update command.""" + client = await ws_client() + await client.cmd_result( + "create", + { + "entity_id": TEST_ENTITY, + "event": { + "summary": "Bastille Day Party", + "dtstart": "1997-07-14T17:00:00+00:00", + "dtend": "1997-07-15T04:00:00+00:00", + }, + }, + ) + + events = await get_events("1997-07-14T00:00:00", "1997-07-16T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party", + "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, + "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + } + ] + uid = events[0]["uid"] + + # Update the event + await client.cmd_result( + "update", + { + "entity_id": TEST_ENTITY, + "uid": uid, + "event": { + "summary": "Bastille Day Party [To be rescheduled]", + "dtstart": "1997-07-14", + "dtend": "1997-07-15", + }, + }, + ) + events = await get_events("1997-07-14T00:00:00", "1997-07-16T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party [To be rescheduled]", + "start": {"date": "1997-07-14"}, + "end": {"date": "1997-07-15"}, + } + ] + + +async def test_websocket_update_recurring_this_and_future( + ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn +): + """Test updating a recurring event.""" + client = await ws_client() + await client.cmd_result( + "create", + { + "entity_id": TEST_ENTITY, + "event": { + "summary": "Morning Routine", + "dtstart": "2022-08-22T08:30:00", + "dtend": "2022-08-22T09:00:00", + "rrule": "FREQ=DAILY", + }, + }, + ) + + events = await get_events("2022-08-22T00:00:00", "2022-08-26T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-22T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-22T09:00:00-06:00"}, + "recurrence_id": "20220822T083000", + }, + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-23T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-23T09:00:00-06:00"}, + "recurrence_id": "20220823T083000", + }, + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-24T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-24T09:00:00-06:00"}, + "recurrence_id": "20220824T083000", + }, + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-25T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-25T09:00:00-06:00"}, + "recurrence_id": "20220825T083000", + }, + ] + uid = events[0]["uid"] + assert [event["uid"] for event in events] == [uid] * 4 + + # Update a single instance and confirm the change is reflected + await client.cmd_result( + "update", + { + "entity_id": TEST_ENTITY, + "uid": uid, + "recurrence_id": "20220824T083000", + "recurrence_range": "THISANDFUTURE", + "event": { + "summary": "Morning Routine [Adjusted]", + "dtstart": "2022-08-24T08:00:00", + "dtend": "2022-08-24T08:30:00", + }, + }, + ) + events = await get_events("2022-08-22T00:00:00", "2022-08-26T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-22T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-22T09:00:00-06:00"}, + "recurrence_id": "20220822T083000", + }, + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-23T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-23T09:00:00-06:00"}, + "recurrence_id": "20220823T083000", + }, + { + "summary": "Morning Routine [Adjusted]", + "start": {"dateTime": "2022-08-24T08:00:00-06:00"}, + "end": {"dateTime": "2022-08-24T08:30:00-06:00"}, + "recurrence_id": "20220824T080000", + }, + { + "summary": "Morning Routine [Adjusted]", + "start": {"dateTime": "2022-08-25T08:00:00-06:00"}, + "end": {"dateTime": "2022-08-25T08:30:00-06:00"}, + "recurrence_id": "20220825T080000", + }, + ] + + +async def test_websocket_update_recurring( + ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn +): + """Test updating a recurring event.""" + client = await ws_client() + await client.cmd_result( + "create", + { + "entity_id": TEST_ENTITY, + "event": { + "summary": "Morning Routine", + "dtstart": "2022-08-22T08:30:00", + "dtend": "2022-08-22T09:00:00", + "rrule": "FREQ=DAILY", + }, + }, + ) + + events = await get_events("2022-08-22T00:00:00", "2022-08-26T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-22T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-22T09:00:00-06:00"}, + "recurrence_id": "20220822T083000", + }, + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-23T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-23T09:00:00-06:00"}, + "recurrence_id": "20220823T083000", + }, + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-24T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-24T09:00:00-06:00"}, + "recurrence_id": "20220824T083000", + }, + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-25T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-25T09:00:00-06:00"}, + "recurrence_id": "20220825T083000", + }, + ] + uid = events[0]["uid"] + assert [event["uid"] for event in events] == [uid] * 4 + + # Update a single instance and confirm the change is reflected + await client.cmd_result( + "update", + { + "entity_id": TEST_ENTITY, + "uid": uid, + "recurrence_id": "20220824T083000", + "event": { + "summary": "Morning Routine [Adjusted]", + "dtstart": "2022-08-24T08:00:00", + "dtend": "2022-08-24T08:30:00", + }, + }, + ) + events = await get_events("2022-08-22T00:00:00", "2022-08-26T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-22T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-22T09:00:00-06:00"}, + "recurrence_id": "20220822T083000", + }, + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-23T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-23T09:00:00-06:00"}, + "recurrence_id": "20220823T083000", + }, + { + "summary": "Morning Routine [Adjusted]", + "start": {"dateTime": "2022-08-24T08:00:00-06:00"}, + "end": {"dateTime": "2022-08-24T08:30:00-06:00"}, + }, + { + "summary": "Morning Routine", + "start": {"dateTime": "2022-08-25T08:30:00-06:00"}, + "end": {"dateTime": "2022-08-25T09:00:00-06:00"}, + "recurrence_id": "20220825T083000", + }, + ] + + @pytest.mark.parametrize( "rrule", [ @@ -704,3 +936,27 @@ async def test_invalid_date_formats( assert "error" in result assert "code" in result.get("error") assert result["error"]["code"] == "invalid_format" + + +async def test_update_invalid_event_id( + ws_client: ClientFixture, + setup_integration: None, + hass: HomeAssistant, +): + """Test updating an event with an invalid event uid.""" + client = await ws_client() + resp = await client.cmd( + "update", + { + "entity_id": TEST_ENTITY, + "uid": "uid-does-not-exist", + "event": { + "summary": "Bastille Day Party [To be rescheduled]", + "dtstart": "1997-07-14", + "dtend": "1997-07-15", + }, + }, + ) + assert not resp.get("success") + assert "error" in resp + assert resp.get("error").get("code") == "failed"