diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index eff26c2fbc4..889e301617a 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -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, diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index ad27e971ece..88e75499678 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -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 diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 0e53642548d..a9c6437354a 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -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"], + } diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index d8ddd6fe588..bce3f4855c7 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -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, ) diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 5e7696eec68..4bd09a5f49b 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -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]})