Add create and delete for Google Calendar events (#83034)

* Add Google Calendar create/delete support

Includes editing for recurring events

* Fix default calendar access role

* Formatting improvements

* Address other details that have changed due to local sync

* Update tests/components/google/test_calendar.py

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

* Update tests/components/google/test_calendar.py

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

* Update tests/components/google/test_calendar.py

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

* Increase test coverage

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2022-12-01 12:39:58 -08:00 committed by GitHub
parent e2308fd15c
commit 5d1ca73a34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 456 additions and 24 deletions

View file

@ -8,7 +8,12 @@ from datetime import datetime, timedelta
import logging
from typing import Any
from gcal_sync.api import GoogleCalendarService, ListEventsRequest, SyncEventsRequest
from gcal_sync.api import (
GoogleCalendarService,
ListEventsRequest,
Range,
SyncEventsRequest,
)
from gcal_sync.exceptions import ApiException
from gcal_sync.model import AccessRole, DateOrDatetime, Event
from gcal_sync.store import ScopedCalendarStore
@ -18,7 +23,13 @@ import voluptuous as vol
from homeassistant.components.calendar import (
ENTITY_ID_FORMAT,
EVENT_DESCRIPTION,
EVENT_END,
EVENT_RRULE,
EVENT_START,
EVENT_SUMMARY,
CalendarEntity,
CalendarEntityFeature,
CalendarEvent,
extract_offset,
is_offset_reached,
@ -52,11 +63,9 @@ from . import (
load_config,
update_config,
)
from .api import get_feature_access
from .const import (
DATA_SERVICE,
DATA_STORE,
EVENT_DESCRIPTION,
EVENT_END_DATE,
EVENT_END_DATETIME,
EVENT_IN,
@ -64,9 +73,7 @@ from .const import (
EVENT_IN_WEEKS,
EVENT_START_DATE,
EVENT_START_DATETIME,
EVENT_SUMMARY,
EVENT_TYPES_CONF,
FeatureAccess,
)
_LOGGER = logging.getLogger(__name__)
@ -235,6 +242,7 @@ async def async_setup_entry(
generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass),
unique_id,
entity_enabled,
calendar_item.access_role.is_writer,
)
)
@ -250,7 +258,7 @@ async def async_setup_entry(
await hass.async_add_executor_job(append_calendars_to_config)
platform = entity_platform.async_get_current_platform()
if get_feature_access(hass, config_entry) is FeatureAccess.read_write:
if any(calendar_item.access_role.is_writer for calendar_item in result.items):
platform.async_register_entity_service(
SERVICE_CREATE_EVENT,
CREATE_EVENT_SCHEMA,
@ -382,6 +390,7 @@ class GoogleCalendarEntity(CoordinatorEntity, CalendarEntity):
entity_id: str,
unique_id: str | None,
entity_enabled: bool,
supports_write: bool,
) -> None:
"""Create the Calendar event device."""
super().__init__(coordinator)
@ -395,6 +404,10 @@ class GoogleCalendarEntity(CoordinatorEntity, CalendarEntity):
self.entity_id = entity_id
self._attr_unique_id = unique_id
self._attr_entity_registry_enabled_default = entity_enabled
if supports_write:
self._attr_supported_features = (
CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.DELETE_EVENT
)
@property
def should_poll(self) -> bool:
@ -486,10 +499,62 @@ class GoogleCalendarEntity(CoordinatorEntity, CalendarEntity):
started, handled by CalendarEntity parent class.
"""
async def async_create_event(self, **kwargs: Any) -> None:
"""Add a new event to calendar."""
dtstart = kwargs[EVENT_START]
dtend = kwargs[EVENT_END]
start: DateOrDatetime
end: DateOrDatetime
if isinstance(dtstart, datetime):
start = DateOrDatetime(
date_time=dt_util.as_local(dtstart),
timezone=str(dt_util.DEFAULT_TIME_ZONE),
)
end = DateOrDatetime(
date_time=dt_util.as_local(dtend),
timezone=str(dt_util.DEFAULT_TIME_ZONE),
)
else:
start = DateOrDatetime(date=dtstart)
end = DateOrDatetime(date=dtend)
event = Event.parse_obj(
{
EVENT_SUMMARY: kwargs[EVENT_SUMMARY],
"start": start,
"end": end,
EVENT_DESCRIPTION: kwargs.get(EVENT_DESCRIPTION),
}
)
if rrule := kwargs.get(EVENT_RRULE):
event.recurrence = [rrule]
await self.coordinator.sync.store_service.async_add_event(event)
await self.coordinator.async_refresh()
async def async_delete_event(
self,
uid: str,
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Delete an event on the calendar."""
range_value: Range = Range.NONE
if recurrence_range == Range.THIS_AND_FUTURE:
range_value = Range.THIS_AND_FUTURE
await self.coordinator.sync.store_service.async_delete_event(
ical_uuid=uid,
event_id=recurrence_id,
recurrence_range=range_value,
)
await self.coordinator.async_refresh()
def _get_calendar_event(event: Event) -> CalendarEvent:
"""Return a CalendarEvent from an API event."""
return CalendarEvent(
uid=event.ical_uuid,
recurrence_id=event.id if event.recurring_event_id else None,
rrule=event.recurrence[0] if len(event.recurrence) == 1 else None,
summary=event.summary,
start=event.start.value,
end=event.end.value,

View file

@ -64,7 +64,7 @@ CLIENT_SECRET = "client-secret"
@pytest.fixture(name="calendar_access_role")
def test_calendar_access_role() -> str:
"""Default access role to use for test_api_calendar in tests."""
return "reader"
return "owner"
@pytest.fixture

View file

@ -2,17 +2,21 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
import datetime
from http import HTTPStatus
from typing import Any
from unittest.mock import patch
import urllib
from aiohttp import ClientWebSocketResponse
from aiohttp.client_exceptions import ClientError
from gcal_sync.auth import API_BASE_URL
import pytest
from homeassistant.components.google.const import DOMAIN
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.template import DATE_STR_FORMAT
import homeassistant.util.dt as dt_util
@ -23,9 +27,12 @@ from .conftest import (
TEST_API_ENTITY_NAME,
TEST_YAML_ENTITY,
TEST_YAML_ENTITY_NAME,
ApiResult,
ComponentSetup,
)
from tests.common import async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker
TEST_ENTITY = TEST_API_ENTITY
TEST_ENTITY_NAME = TEST_API_ENTITY_NAME
@ -60,14 +67,6 @@ TEST_EVENT = {
}
@pytest.fixture(
autouse=True, scope="module", params=["reader", "owner", "freeBusyReader"]
)
def calendar_access_role(request) -> str:
"""Fixture to exercise access roles in tests."""
return request.param
@pytest.fixture(autouse=True)
def mock_test_setup(
test_api_calendar,
@ -99,8 +98,55 @@ def upcoming_event_url(entity: str = TEST_ENTITY) -> str:
return get_events_url(entity, start, end)
class Client:
"""Test client with helper methods for calendar websocket."""
def __init__(self, client):
"""Initialize Client."""
self.client = client
self.id = 0
async def cmd(self, cmd: str, payload: dict[str, Any] = None) -> dict[str, Any]:
"""Send a command and receive the json result."""
self.id += 1
await self.client.send_json(
{
"id": self.id,
"type": f"calendar/event/{cmd}",
**(payload if payload is not None else {}),
}
)
resp = await self.client.receive_json()
assert resp.get("id") == self.id
return resp
async def cmd_result(self, cmd: str, payload: dict[str, Any] = None) -> Any:
"""Send a command and parse the result."""
resp = await self.cmd(cmd, payload)
assert resp.get("success")
assert resp.get("type") == "result"
return resp.get("result")
ClientFixture = Callable[[], Awaitable[Client]]
@pytest.fixture
async def ws_client(
hass: HomeAssistant,
hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]],
) -> ClientFixture:
"""Fixture for creating the test websocket client."""
async def create_client() -> Client:
ws_client = await hass_ws_client(hass)
return Client(ws_client)
return create_client
async def test_all_day_event(hass, mock_events_list_items, component_setup):
"""Test that we can create an event trigger on device."""
"""Test for an all day calendar event."""
week_from_today = dt_util.now().date() + datetime.timedelta(days=7)
end_event = week_from_today + datetime.timedelta(days=1)
event = {
@ -124,11 +170,12 @@ async def test_all_day_event(hass, mock_events_list_items, component_setup):
"end_time": end_event.strftime(DATE_STR_FORMAT),
"location": event["location"],
"description": event["description"],
"supported_features": 3,
}
async def test_future_event(hass, mock_events_list_items, component_setup):
"""Test that we can create an event trigger on device."""
"""Test for an upcoming event."""
one_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30)
end_event = one_hour_from_now + datetime.timedelta(minutes=60)
event = {
@ -152,11 +199,12 @@ async def test_future_event(hass, mock_events_list_items, component_setup):
"end_time": end_event.strftime(DATE_STR_FORMAT),
"location": event["location"],
"description": event["description"],
"supported_features": 3,
}
async def test_in_progress_event(hass, mock_events_list_items, component_setup):
"""Test that we can create an event trigger on device."""
"""Test an event that is active now."""
middle_of_event = dt_util.now() - datetime.timedelta(minutes=30)
end_event = middle_of_event + datetime.timedelta(minutes=60)
event = {
@ -180,11 +228,12 @@ async def test_in_progress_event(hass, mock_events_list_items, component_setup):
"end_time": end_event.strftime(DATE_STR_FORMAT),
"location": event["location"],
"description": event["description"],
"supported_features": 3,
}
async def test_offset_in_progress_event(hass, mock_events_list_items, component_setup):
"""Test that we can create an event trigger on device."""
"""Test an event that is active now with an offset."""
middle_of_event = dt_util.now() + datetime.timedelta(minutes=14)
end_event = middle_of_event + datetime.timedelta(minutes=60)
event_summary = "Test Event in Progress"
@ -210,13 +259,14 @@ async def test_offset_in_progress_event(hass, mock_events_list_items, component_
"end_time": end_event.strftime(DATE_STR_FORMAT),
"location": event["location"],
"description": event["description"],
"supported_features": 3,
}
async def test_all_day_offset_in_progress_event(
hass, mock_events_list_items, component_setup
):
"""Test that we can create an event trigger on device."""
"""Test an all day event that is currently in progress due to an offset."""
tomorrow = dt_util.now().date() + datetime.timedelta(days=1)
end_event = tomorrow + datetime.timedelta(days=1)
event_summary = "Test All Day Event Offset In Progress"
@ -242,11 +292,12 @@ async def test_all_day_offset_in_progress_event(
"end_time": end_event.strftime(DATE_STR_FORMAT),
"location": event["location"],
"description": event["description"],
"supported_features": 3,
}
async def test_all_day_offset_event(hass, mock_events_list_items, component_setup):
"""Test that we can create an event trigger on device."""
"""Test an all day event that not in progress due to an offset."""
now = dt_util.now()
day_after_tomorrow = now.date() + datetime.timedelta(days=2)
end_event = day_after_tomorrow + datetime.timedelta(days=1)
@ -274,11 +325,12 @@ async def test_all_day_offset_event(hass, mock_events_list_items, component_setu
"end_time": end_event.strftime(DATE_STR_FORMAT),
"location": event["location"],
"description": event["description"],
"supported_features": 3,
}
async def test_missing_summary(hass, mock_events_list_items, component_setup):
"""Test that we can create an event trigger on device."""
"""Test that a summary is optional."""
start_event = dt_util.now() + datetime.timedelta(minutes=14)
end_event = start_event + datetime.timedelta(minutes=60)
event = {
@ -303,6 +355,7 @@ async def test_missing_summary(hass, mock_events_list_items, component_setup):
"end_time": end_event.strftime(DATE_STR_FORMAT),
"location": event["location"],
"description": event["description"],
"supported_features": 3,
}
@ -779,3 +832,317 @@ async def test_all_day_iter_order(
assert response.status == HTTPStatus.OK
events = await response.json()
assert [event["summary"] for event in events] == event_order
async def test_websocket_create(
hass: HomeAssistant,
component_setup: ComponentSetup,
test_api_calendar: dict[str, Any],
mock_insert_event: Callable[[str, dict[str, Any]], None],
mock_events_list: ApiResult,
aioclient_mock: AiohttpClientMocker,
ws_client: ClientFixture,
) -> None:
"""Test websocket create command that sets a date/time range."""
mock_events_list({})
assert await component_setup()
aioclient_mock.clear_requests()
mock_insert_event(
calendar_id=CALENDAR_ID,
)
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",
},
},
)
assert len(aioclient_mock.mock_calls) == 1
assert aioclient_mock.mock_calls[0][2] == {
"summary": "Bastille Day Party",
"description": None,
"start": {
"dateTime": "1997-07-14T11:00:00-06:00",
"timeZone": "America/Regina",
},
"end": {"dateTime": "1997-07-14T22:00:00-06:00", "timeZone": "America/Regina"},
}
async def test_websocket_create_all_day(
hass: HomeAssistant,
component_setup: ComponentSetup,
test_api_calendar: dict[str, Any],
mock_insert_event: Callable[[str, dict[str, Any]], None],
mock_events_list: ApiResult,
aioclient_mock: AiohttpClientMocker,
ws_client: ClientFixture,
) -> None:
"""Test websocket create command for an all day event."""
mock_events_list({})
assert await component_setup()
aioclient_mock.clear_requests()
mock_insert_event(
calendar_id=CALENDAR_ID,
)
client = await ws_client()
await client.cmd_result(
"create",
{
"entity_id": TEST_ENTITY,
"event": {
"summary": "Bastille Day Party",
"dtstart": "1997-07-14",
"dtend": "1997-07-15",
"rrule": "FREQ=YEARLY",
},
},
)
assert len(aioclient_mock.mock_calls) == 1
assert aioclient_mock.mock_calls[0][2] == {
"summary": "Bastille Day Party",
"description": None,
"start": {
"date": "1997-07-14",
},
"end": {"date": "1997-07-15"},
"recurrence": ["FREQ=YEARLY"],
}
async def test_websocket_delete(
ws_client: ClientFixture,
hass_client,
component_setup,
mock_events_list: ApiResult,
mock_events_list_items: ApiResult,
aioclient_mock,
):
"""Test websocket delete command."""
mock_events_list_items(
[
{
**TEST_EVENT,
"id": "event-id-1",
"iCalUID": "event-id-1@google.com",
"summary": "All Day Event",
"start": {"date": "2022-10-08"},
"end": {"date": "2022-10-09"},
},
]
)
assert await component_setup()
assert len(aioclient_mock.mock_calls) == 2
aioclient_mock.clear_requests()
# Expect a delete request as well as a follow up to sync state from server
aioclient_mock.delete(f"{API_BASE_URL}/calendars/{CALENDAR_ID}/events/event-id-1")
mock_events_list_items([])
client = await ws_client()
await client.cmd_result(
"delete",
{
"entity_id": TEST_ENTITY,
"uid": "event-id-1@google.com",
},
)
assert len(aioclient_mock.mock_calls) == 2
assert aioclient_mock.mock_calls[0][0] == "delete"
async def test_websocket_delete_recurring_event_instance(
ws_client: ClientFixture,
hass_client,
component_setup,
mock_events_list: ApiResult,
mock_events_list_items: ApiResult,
aioclient_mock,
):
"""Test websocket delete command with recurring events."""
mock_events_list_items(
[
{
**TEST_EVENT,
"id": "event-id-1",
"iCalUID": "event-id-1@google.com",
"summary": "All Day Event",
"start": {"date": "2022-10-08"},
"end": {"date": "2022-10-09"},
"recurrence": ["RRULE:FREQ=WEEKLY"],
},
]
)
assert await component_setup()
assert len(aioclient_mock.mock_calls) == 2
# Get a time range for the first event and the second instance of the
# recurring event.
web_client = await hass_client()
response = await web_client.get(
get_events_url(TEST_ENTITY, "2022-10-06T00:00:00Z", "2022-10-20T00:00:00Z")
)
assert response.status == HTTPStatus.OK
events = await response.json()
assert len(events) == 2
# Delete the second instance
event = events[1]
assert event["uid"] == "event-id-1@google.com"
assert event["recurrence_id"] == "event-id-1_20221015"
# Expect a delete request as well as a follow up to sync state from server
aioclient_mock.clear_requests()
aioclient_mock.patch(
f"{API_BASE_URL}/calendars/{CALENDAR_ID}/events/event-id-1_20221015"
)
mock_events_list_items([])
client = await ws_client()
await client.cmd_result(
"delete",
{
"entity_id": TEST_ENTITY,
"uid": event["uid"],
"recurrence_id": event["recurrence_id"],
},
)
assert len(aioclient_mock.mock_calls) == 2
assert aioclient_mock.mock_calls[0][0] == "patch"
# Request to cancel the second instance of the recurring event
assert aioclient_mock.mock_calls[0][2] == {
"id": "event-id-1_20221015",
"status": "cancelled",
}
# Attempt delete again, but this time for all future instances
aioclient_mock.clear_requests()
aioclient_mock.patch(f"{API_BASE_URL}/calendars/{CALENDAR_ID}/events/event-id-1")
mock_events_list_items([])
client = await ws_client()
await client.cmd_result(
"delete",
{
"entity_id": TEST_ENTITY,
"uid": event["uid"],
"recurrence_id": event["recurrence_id"],
"recurrence_range": "THISANDFUTURE",
},
)
assert len(aioclient_mock.mock_calls) == 2
assert aioclient_mock.mock_calls[0][0] == "patch"
# Request to cancel all events after the second instance
assert aioclient_mock.mock_calls[0][2] == {
"id": "event-id-1",
"recurrence": ["RRULE:FREQ=WEEKLY;UNTIL=20221015"],
}
@pytest.mark.parametrize(
"calendar_access_role",
["reader"],
)
async def test_readonly_websocket_create(
hass: HomeAssistant,
component_setup: ComponentSetup,
test_api_calendar: dict[str, Any],
mock_insert_event: Callable[[str, dict[str, Any]], None],
mock_events_list: ApiResult,
aioclient_mock: AiohttpClientMocker,
ws_client: ClientFixture,
) -> None:
"""Test websocket create command with read only access."""
mock_events_list({})
assert await component_setup()
aioclient_mock.clear_requests()
mock_insert_event(
calendar_id=CALENDAR_ID,
)
client = await ws_client()
result = await client.cmd(
"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",
},
},
)
assert result.get("error")
assert result["error"].get("code") == "not_supported"
@pytest.mark.parametrize("calendar_access_role", ["reader", "freeBusyReader"])
async def test_all_day_reader_access(hass, mock_events_list_items, component_setup):
"""Test that reader / freebusy reader access can load properly."""
week_from_today = dt_util.now().date() + datetime.timedelta(days=7)
end_event = week_from_today + datetime.timedelta(days=1)
event = {
**TEST_EVENT,
"start": {"date": week_from_today.isoformat()},
"end": {"date": end_event.isoformat()},
}
mock_events_list_items([event])
assert await component_setup()
state = hass.states.get(TEST_ENTITY)
assert state.name == TEST_ENTITY_NAME
assert state.state == STATE_OFF
assert dict(state.attributes) == {
"friendly_name": TEST_ENTITY_NAME,
"message": event["summary"],
"all_day": True,
"offset_reached": False,
"start_time": week_from_today.strftime(DATE_STR_FORMAT),
"end_time": end_event.strftime(DATE_STR_FORMAT),
"location": event["location"],
"description": event["description"],
}
@pytest.mark.parametrize("calendar_access_role", ["reader", "freeBusyReader"])
async def test_reader_in_progress_event(hass, mock_events_list_items, component_setup):
"""Test reader access for an event in process."""
middle_of_event = dt_util.now() - datetime.timedelta(minutes=30)
end_event = middle_of_event + datetime.timedelta(minutes=60)
event = {
**TEST_EVENT,
"start": {"dateTime": middle_of_event.isoformat()},
"end": {"dateTime": end_event.isoformat()},
}
mock_events_list_items([event])
assert await component_setup()
state = hass.states.get(TEST_ENTITY)
assert state.name == TEST_ENTITY_NAME
assert state.state == STATE_ON
assert dict(state.attributes) == {
"friendly_name": TEST_ENTITY_NAME,
"message": event["summary"],
"all_day": False,
"offset_reached": False,
"start_time": middle_of_event.strftime(DATE_STR_FORMAT),
"end_time": end_event.strftime(DATE_STR_FORMAT),
"location": event["location"],
"description": event["description"],
}

View file

@ -104,7 +104,7 @@ async def primary_calendar(
"""Fixture to return the primary calendar."""
mock_calendar_get(
"primary",
{"id": primary_calendar_email, "summary": "Personal"},
{"id": primary_calendar_email, "summary": "Personal", "accessRole": "owner"},
exc=primary_calendar_error,
)

View file

@ -768,7 +768,7 @@ async def test_assign_unique_id(
mock_calendar_get(
"primary",
{"id": EMAIL_ADDRESS, "summary": "Personal"},
{"id": EMAIL_ADDRESS, "summary": "Personal", "accessRole": "owner"},
)
mock_calendars_list({"items": [test_api_calendar]})