diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 1ddb44e570b..e86f1c43ebf 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -43,6 +43,16 @@ from .const import ( DATA_SERVICE, DEVICE_AUTH_IMPL, DOMAIN, + EVENT_DESCRIPTION, + EVENT_END_DATE, + EVENT_END_DATETIME, + EVENT_IN, + EVENT_IN_DAYS, + EVENT_IN_WEEKS, + EVENT_START_DATE, + EVENT_START_DATETIME, + EVENT_SUMMARY, + EVENT_TYPES_CONF, FeatureAccess, ) @@ -61,18 +71,6 @@ CONF_MAX_RESULTS = "max_results" DEFAULT_CONF_OFFSET = "!!" EVENT_CALENDAR_ID = "calendar_id" -EVENT_DESCRIPTION = "description" -EVENT_END_CONF = "end" -EVENT_END_DATE = "end_date" -EVENT_END_DATETIME = "end_date_time" -EVENT_IN = "in" -EVENT_IN_DAYS = "days" -EVENT_IN_WEEKS = "weeks" -EVENT_START_CONF = "start" -EVENT_START_DATE = "start_date" -EVENT_START_DATETIME = "start_date_time" -EVENT_SUMMARY = "summary" -EVENT_TYPES_CONF = "event_types" NOTIFICATION_ID = "google_calendar_notification" NOTIFICATION_TITLE = "Google Calendar Setup" @@ -138,17 +136,31 @@ _EVENT_IN_TYPES = vol.Schema( } ) -ADD_EVENT_SERVICE_SCHEMA = vol.Schema( +ADD_EVENT_SERVICE_SCHEMA = vol.All( + cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), + cv.has_at_most_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), { vol.Required(EVENT_CALENDAR_ID): cv.string, vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, - vol.Exclusive(EVENT_START_DATE, EVENT_START_CONF): cv.date, - vol.Exclusive(EVENT_END_DATE, EVENT_END_CONF): cv.date, - vol.Exclusive(EVENT_START_DATETIME, EVENT_START_CONF): cv.datetime, - vol.Exclusive(EVENT_END_DATETIME, EVENT_END_CONF): cv.datetime, - vol.Exclusive(EVENT_IN, EVENT_START_CONF, EVENT_END_CONF): _EVENT_IN_TYPES, - } + vol.Inclusive( + EVENT_START_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_END_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_START_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Inclusive( + EVENT_END_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Optional(EVENT_IN): _EVENT_IN_TYPES, + }, ) @@ -276,6 +288,12 @@ async def async_setup_add_event_service( async def _add_event(call: ServiceCall) -> None: """Add a new event to calendar.""" + _LOGGER.warning( + "The Google Calendar add_event service has been deprecated, and " + "will be removed in a future Home Assistant release. Please move " + "calls to the create_event service" + ) + start: DateOrDatetime | None = None end: DateOrDatetime | None = None @@ -298,11 +316,11 @@ async def async_setup_add_event_service( start = DateOrDatetime(date=start_in) end = DateOrDatetime(date=end_in) - elif EVENT_START_DATE in call.data: + elif EVENT_START_DATE in call.data and EVENT_END_DATE in call.data: start = DateOrDatetime(date=call.data[EVENT_START_DATE]) end = DateOrDatetime(date=call.data[EVENT_END_DATE]) - elif EVENT_START_DATETIME in call.data: + elif EVENT_START_DATETIME in call.data and EVENT_END_DATETIME in call.data: start_dt = call.data[EVENT_START_DATETIME] end_dt = call.data[EVENT_END_DATETIME] start = DateOrDatetime( diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 78661ed792f..39e3d69e6b9 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -9,7 +9,8 @@ from typing import Any from gcal_sync.api import GoogleCalendarService, ListEventsRequest from gcal_sync.exceptions import ApiException -from gcal_sync.model import Event +from gcal_sync.model import DateOrDatetime, Event +import voluptuous as vol from homeassistant.components.calendar import ( ENTITY_ID_FORMAT, @@ -20,8 +21,9 @@ from homeassistant.components.calendar import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle @@ -38,22 +40,66 @@ from . import ( load_config, update_config, ) +from .api import get_feature_access +from .const import ( + EVENT_DESCRIPTION, + EVENT_END_DATE, + EVENT_END_DATETIME, + EVENT_IN, + EVENT_IN_DAYS, + EVENT_IN_WEEKS, + EVENT_START_DATE, + EVENT_START_DATETIME, + EVENT_SUMMARY, + EVENT_TYPES_CONF, + FeatureAccess, +) _LOGGER = logging.getLogger(__name__) -DEFAULT_GOOGLE_SEARCH_PARAMS = { - "orderBy": "startTime", - "singleEvents": True, -} - MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) # Events have a transparency that determine whether or not they block time on calendar. # When an event is opaque, it means "Show me as busy" which is the default. Events that # are not opaque are ignored by default. -TRANSPARENCY = "transparency" OPAQUE = "opaque" +_EVENT_IN_TYPES = vol.Schema( + { + vol.Exclusive(EVENT_IN_DAYS, EVENT_TYPES_CONF): cv.positive_int, + vol.Exclusive(EVENT_IN_WEEKS, EVENT_TYPES_CONF): cv.positive_int, + } +) + +SERVICE_CREATE_EVENT = "create_event" +CREATE_EVENT_SCHEMA = vol.All( + cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), + cv.has_at_most_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), + cv.make_entity_service_schema( + { + vol.Required(EVENT_SUMMARY): cv.string, + vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Inclusive( + EVENT_START_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_END_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_START_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Inclusive( + EVENT_END_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Optional(EVENT_IN): _EVENT_IN_TYPES, + } + ), +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -116,6 +162,14 @@ 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, entry) is FeatureAccess.read_write: + platform.async_register_entity_service( + SERVICE_CREATE_EVENT, + CREATE_EVENT_SCHEMA, + async_create_event, + ) + class GoogleCalendarEntity(CalendarEntity): """A calendar event device.""" @@ -130,8 +184,8 @@ class GoogleCalendarEntity(CalendarEntity): entity_enabled: bool, ) -> None: """Create the Calendar event device.""" - self._calendar_service = calendar_service - self._calendar_id = calendar_id + self.calendar_service = calendar_service + self.calendar_id = calendar_id self._search: str | None = data.get(CONF_SEARCH) self._ignore_availability: bool = data.get(CONF_IGNORE_AVAILABILITY, False) self._event: CalendarEvent | None = None @@ -178,14 +232,14 @@ class GoogleCalendarEntity(CalendarEntity): """Get all events in a specific time frame.""" request = ListEventsRequest( - calendar_id=self._calendar_id, + calendar_id=self.calendar_id, start_time=start_date, end_time=end_date, search=self._search, ) result_items = [] try: - result = await self._calendar_service.async_list_events(request) + result = await self.calendar_service.async_list_events(request) async for result_page in result: result_items.extend(result_page.items) except ApiException as err: @@ -199,9 +253,9 @@ class GoogleCalendarEntity(CalendarEntity): @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: """Get the latest data.""" - request = ListEventsRequest(calendar_id=self._calendar_id, search=self._search) + request = ListEventsRequest(calendar_id=self.calendar_id, search=self._search) try: - result = await self._calendar_service.async_list_events(request) + result = await self.calendar_service.async_list_events(request) except ApiException as err: _LOGGER.error("Unable to connect to Google: %s", err) return @@ -226,3 +280,52 @@ def _get_calendar_event(event: Event) -> CalendarEvent: description=event.description, location=event.location, ) + + +async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> None: + """Add a new event to calendar.""" + start: DateOrDatetime | None = None + end: DateOrDatetime | None = None + hass = entity.hass + + if EVENT_IN in call.data: + if EVENT_IN_DAYS in call.data[EVENT_IN]: + now = datetime.now() + + start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS]) + end_in = start_in + timedelta(days=1) + + start = DateOrDatetime(date=start_in) + end = DateOrDatetime(date=end_in) + + elif EVENT_IN_WEEKS in call.data[EVENT_IN]: + now = datetime.now() + + start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) + end_in = start_in + timedelta(days=1) + + start = DateOrDatetime(date=start_in) + end = DateOrDatetime(date=end_in) + + elif EVENT_START_DATE in call.data and EVENT_END_DATE in call.data: + start = DateOrDatetime(date=call.data[EVENT_START_DATE]) + end = DateOrDatetime(date=call.data[EVENT_END_DATE]) + + elif EVENT_START_DATETIME in call.data and EVENT_END_DATETIME in call.data: + start_dt = call.data[EVENT_START_DATETIME] + end_dt = call.data[EVENT_END_DATETIME] + start = DateOrDatetime(date_time=start_dt, timezone=str(hass.config.time_zone)) + end = DateOrDatetime(date_time=end_dt, timezone=str(hass.config.time_zone)) + + if start is None or end is None: + raise ValueError("Missing required fields to set start or end date/datetime") + + await entity.calendar_service.async_create_event( + entity.calendar_id, + Event( + summary=call.data[EVENT_SUMMARY], + description=call.data[EVENT_DESCRIPTION], + start=start, + end=end, + ), + ) diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index fba9b01b600..f07958c2e6e 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -29,3 +29,15 @@ class FeatureAccess(Enum): DEFAULT_FEATURE_ACCESS = FeatureAccess.read_write + + +EVENT_DESCRIPTION = "description" +EVENT_END_DATE = "end_date" +EVENT_END_DATETIME = "end_date_time" +EVENT_IN = "in" +EVENT_IN_DAYS = "days" +EVENT_IN_WEEKS = "weeks" +EVENT_START_DATE = "start_date" +EVENT_START_DATETIME = "start_date_time" +EVENT_SUMMARY = "summary" +EVENT_TYPES_CONF = "event_types" diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml index baa069aaedf..a303ad7e18d 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -52,3 +52,54 @@ add_event: example: '"days": 2 or "weeks": 2' selector: object: +create_event: + name: Create event + description: Add a new calendar event. + target: + entity: + integration: google + domain: calendar + fields: + summary: + name: Summary + description: Acts as the title of the event. + required: true + example: "Bowling" + selector: + text: + description: + name: Description + description: The description of the event. Optional. + example: "Birthday bowling" + selector: + text: + start_date_time: + name: Start time + description: The date and time the event should start. + example: "2022-03-22 20:00:00" + selector: + text: + end_date_time: + name: End time + description: The date and time the event should end. + example: "2022-03-22 22:00:00" + selector: + text: + start_date: + name: Start date + description: The date the whole day event should start. + example: "2022-03-10" + selector: + text: + end_date: + name: End date + description: The date the whole day event should end. + example: "2022-03-11" + selector: + text: + in: + name: In + description: Days or weeks that you want to create the event in. + example: '"days": 2 or "weeks": 2' + selector: + object: diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index cadb444c26f..b2a81b47718 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -9,12 +9,14 @@ from typing import Any from unittest.mock import Mock, patch import pytest +import voluptuous as vol from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) from homeassistant.components.google import DOMAIN, SERVICE_ADD_EVENT +from homeassistant.components.google.calendar import SERVICE_CREATE_EVENT from homeassistant.components.google.const import CONF_CALENDAR_ACCESS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF @@ -40,6 +42,9 @@ EXPIRED_TOKEN_TIMESTAMP = datetime.datetime(2022, 4, 8).timestamp() # Typing helpers HassApi = Callable[[], Awaitable[dict[str, Any]]] +TEST_EVENT_SUMMARY = "Test Summary" +TEST_EVENT_DESCRIPTION = "Test Description" + def assert_state(actual: State | None, expected: State | None) -> None: """Assert that the two states are equal.""" @@ -59,6 +64,45 @@ def setup_config_entry( config_entry.add_to_hass(hass) +@pytest.fixture( + params=[ + ( + SERVICE_ADD_EVENT, + {"calendar_id": CALENDAR_ID}, + None, + ), + ( + SERVICE_CREATE_EVENT, + {}, + {"entity_id": TEST_YAML_ENTITY}, + ), + ], + ids=("add_event", "create_event"), +) +def add_event_call_service( + hass: HomeAssistant, + request: Any, +) -> Callable[dict[str, Any], Awaitable[None]]: + """Fixture for calling the add or create event service.""" + (service_call, data, target) = request.param + + async def call_service(params: dict[str, Any]) -> None: + await hass.services.async_call( + DOMAIN, + service_call, + { + **data, + **params, + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, + }, + target=target, + blocking=True, + ) + + return call_service + + async def test_unload_entry( hass: HomeAssistant, component_setup: ComponentSetup, @@ -297,28 +341,145 @@ async def test_calendar_config_track_new( assert_state(state, expected_state) -async def test_add_event_missing_required_fields( +@pytest.mark.parametrize( + "date_fields,expected_error,error_match", + [ + ( + {}, + vol.error.MultipleInvalid, + "must contain at least one of start_date, start_date_time, in", + ), + ( + { + "start_date": "2022-04-01", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "end_date": "2022-04-02", + }, + vol.error.MultipleInvalid, + "must contain at least one of start_date, start_date_time, in.", + ), + ( + { + "start_date_time": "2022-04-01T06:00:00", + }, + vol.error.MultipleInvalid, + "Start and end datetimes must both be specified", + ), + ( + { + "end_date_time": "2022-04-02T07:00:00", + }, + vol.error.MultipleInvalid, + "must contain at least one of start_date, start_date_time, in.", + ), + ( + { + "start_date": "2022-04-01", + "start_date_time": "2022-04-01T06:00:00", + "end_date_time": "2022-04-02T07:00:00", + }, + vol.error.MultipleInvalid, + "must contain at most one of start_date, start_date_time, in.", + ), + ( + { + "start_date_time": "2022-04-01T06:00:00", + "end_date_time": "2022-04-01T07:00:00", + "end_date": "2022-04-02", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "start_date": "2022-04-01", + "end_date_time": "2022-04-02T07:00:00", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "start_date_time": "2022-04-01T07:00:00", + "end_date": "2022-04-02", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "in": { + "days": 2, + "weeks": 2, + } + }, + vol.error.MultipleInvalid, + "two or more values in the same group of exclusion 'event_types'", + ), + ( + { + "start_date": "2022-04-01", + "end_date": "2022-04-02", + "in": { + "days": 2, + }, + }, + vol.error.MultipleInvalid, + "must contain at most one of start_date, start_date_time, in.", + ), + ( + { + "start_date_time": "2022-04-01T07:00:00", + "end_date_time": "2022-04-01T07:00:00", + "in": { + "days": 2, + }, + }, + vol.error.MultipleInvalid, + "must contain at most one of start_date, start_date_time, in.", + ), + ], + ids=[ + "missing_all", + "missing_end_date", + "missing_start_date", + "missing_end_datetime", + "missing_start_datetime", + "multiple_start", + "multiple_end", + "missing_end_date", + "missing_end_date_time", + "multiple_in", + "unexpected_in_with_date", + "unexpected_in_with_datetime", + ], +) +async def test_add_event_invalid_params( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], + mock_calendars_yaml: None, + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], + date_fields: dict[str, Any], + expected_error: type[Exception], + error_match: str | None, ) -> None: - """Test service call that adds an event missing required fields.""" + """Test service calls with incorrect fields.""" + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() - with pytest.raises(ValueError): - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, - { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", - }, - blocking=True, - ) + with pytest.raises(expected_error, match=error_match): + await add_event_call_service(date_fields) @pytest.mark.parametrize( @@ -343,40 +504,35 @@ async def test_add_event_date_in_x( mock_calendars_list: ApiResult, mock_insert_event: Callable[[..., dict[str, Any]], None], test_api_calendar: dict[str, Any], + mock_calendars_yaml: None, + mock_events_list: ApiResult, date_fields: dict[str, Any], start_timedelta: datetime.timedelta, end_timedelta: datetime.timedelta, setup_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that adds an event with various time ranges.""" - mock_calendars_list({}) + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() now = datetime.datetime.now() start_date = now + start_timedelta end_date = now + end_timedelta + aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, - { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", - **date_fields, - }, - blocking=True, - ) - assert len(aioclient_mock.mock_calls) == 2 - assert aioclient_mock.mock_calls[1][2] == { - "summary": "Summary", - "description": "Description", + await add_event_call_service(date_fields) + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, "start": {"date": start_date.date().isoformat()}, "end": {"date": end_date.date().isoformat()}, } @@ -386,39 +542,39 @@ async def test_add_event_date( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], mock_insert_event: Callable[[str, dict[str, Any]], None], + mock_calendars_yaml: None, + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that sets a date range.""" - mock_calendars_list({}) + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() now = utcnow() today = now.date() end_date = today + datetime.timedelta(days=2) + aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, + await add_event_call_service( { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", "start_date": today.isoformat(), "end_date": end_date.isoformat(), }, - blocking=True, ) - assert len(aioclient_mock.mock_calls) == 2 - assert aioclient_mock.mock_calls[1][2] == { - "summary": "Summary", - "description": "Description", + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, "start": {"date": today.isoformat()}, "end": {"date": end_date.isoformat()}, } @@ -430,38 +586,37 @@ async def test_add_event_date_time( mock_calendars_list: ApiResult, mock_insert_event: Callable[[str, dict[str, Any]], None], test_api_calendar: dict[str, Any], + mock_calendars_yaml: None, + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that adds an event with a date time range.""" - mock_calendars_list({}) + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() start_datetime = datetime.datetime.now() delta = datetime.timedelta(days=3, hours=3) end_datetime = start_datetime + delta + aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, + await add_event_call_service( { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", "start_date_time": start_datetime.isoformat(), "end_date_time": end_datetime.isoformat(), }, - blocking=True, ) - assert len(aioclient_mock.mock_calls) == 2 - assert aioclient_mock.mock_calls[1][2] == { - "summary": "Summary", - "description": "Description", + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, "start": { "dateTime": start_datetime.isoformat(timespec="seconds"), "timeZone": "America/Regina",