Add Local calendar edit support (#84141)

* Add update support for calendars and implement in local calendar

* Fix supported feature for update calendar

* Increase test coverage for websocket error cases

* Improve test coverage for update failure cases

* Improve test coverage by sharing code between update and create

* Update homeassistant/components/calendar/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2022-12-27 12:36:43 -08:00 committed by GitHub
parent b01efc55a2
commit 624c93bb38
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 455 additions and 24 deletions

View file

@ -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"])

View file

@ -10,6 +10,7 @@ class CalendarEntityFeature(IntEnum):
CREATE_EVENT = 1
DELETE_EVENT = 2
UPDATE_EVENT = 4
# rfc5545 fields

View file

@ -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."""

View file

@ -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

View file

@ -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"