diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 01c8d4fd5ed..876b90eac9b 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -19,7 +19,7 @@ from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPOR from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -37,15 +37,26 @@ from .const import ( CONF_EVENT, EVENT_DESCRIPTION, EVENT_END, + EVENT_END_DATE, + EVENT_END_DATETIME, + EVENT_IN, + EVENT_IN_DAYS, + EVENT_IN_WEEKS, EVENT_RECURRENCE_ID, EVENT_RECURRENCE_RANGE, EVENT_RRULE, EVENT_START, + EVENT_START_DATE, + EVENT_START_DATETIME, EVENT_SUMMARY, + EVENT_TIME_FIELDS, + EVENT_TYPES, EVENT_UID, CalendarEntityFeature, ) +# mypy: disallow-any-generics + _LOGGER = logging.getLogger(__name__) DOMAIN = "calendar" @@ -55,8 +66,39 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60) # Don't support rrules more often than daily VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"} - -# mypy: disallow-any-generics +CREATE_EVENT_SERVICE = "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): vol.Schema( + { + vol.Exclusive(EVENT_IN_DAYS, EVENT_TYPES): cv.positive_int, + vol.Exclusive(EVENT_IN_WEEKS, EVENT_TYPES): cv.positive_int, + } + ), + }, + ), +) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -76,6 +118,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, handle_calendar_event_delete) websocket_api.async_register_command(hass, handle_calendar_event_update) + component.async_register_entity_service( + CREATE_EVENT_SERVICE, + CREATE_EVENT_SCHEMA, + async_create_event, + required_features=[CalendarEntityFeature.CREATE_EVENT], + ) + await component.async_setup(config) return True @@ -569,3 +618,43 @@ async def handle_calendar_event_update( connection.send_error(msg["id"], "failed", str(ex)) else: connection.send_result(msg["id"]) + + +def _validate_timespan( + values: dict[str, Any] +) -> tuple[datetime.datetime | datetime.date, datetime.datetime | datetime.date]: + """Parse a create event service call and convert the args ofr a create event entity call. + + This converts the input service arguments into a `start` and `end` date or date time. This + exists because service calls use `start_date` and `start_date_time` whereas the + normal entity methods can take either a `datetim` or `date` as a single `start` argument. + It also handles the other service call variations like "in days" as well. + """ + + if event_in := values.get(EVENT_IN): + days = event_in.get(EVENT_IN_DAYS, 7 * event_in.get(EVENT_IN_WEEKS, 0)) + today = datetime.date.today() + return ( + today + datetime.timedelta(days=days), + today + datetime.timedelta(days=days + 1), + ) + + if EVENT_START_DATE in values and EVENT_END_DATE in values: + return (values[EVENT_START_DATE], values[EVENT_END_DATE]) + + if EVENT_START_DATETIME in values and EVENT_END_DATETIME in values: + return (values[EVENT_START_DATETIME], values[EVENT_END_DATETIME]) + + raise ValueError("Missing required fields to set start or end date/datetime") + + +async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None: + """Add a new event to calendar.""" + # Convert parameters to format used by async_create_event + (start, end) = _validate_timespan(call.data) + params = { + **{k: v for k, v in call.data.items() if k not in EVENT_TIME_FIELDS}, + EVENT_START: start, + EVENT_END: end, + } + await entity.async_create_event(**params) diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index 4a29a28d71d..aa47cb3592e 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -23,3 +23,20 @@ EVENT_LOCATION = "location" EVENT_RECURRENCE_ID = "recurrence_id" EVENT_RECURRENCE_RANGE = "recurrence_range" EVENT_RRULE = "rrule" + +# Service call fields +EVENT_START_DATE = "start_date" +EVENT_END_DATE = "end_date" +EVENT_START_DATETIME = "start_date_time" +EVENT_END_DATETIME = "end_date_time" +EVENT_IN = "in" +EVENT_IN_DAYS = "days" +EVENT_IN_WEEKS = "weeks" +EVENT_TIME_FIELDS = { + EVENT_START_DATE, + EVENT_END_DATE, + EVENT_START_DATETIME, + EVENT_END_DATETIME, + EVENT_IN, +} +EVENT_TYPES = "event_types" diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index 8e2958f7370..61a6ae1e0c8 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -1 +1,48 @@ -# Describes the format for available calendar services +create_event: + name: Create event + description: Add a new calendar event. + target: + entity: + domain: calendar + fields: + summary: + name: Summary + description: Defines the short summary or subject for the event + required: true + example: "Department Party" + selector: + text: + description: + name: Description + description: A more complete description of the event than that provided by the summary. + example: "Meeting to provide technical review for 'Phoenix' design." + 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: + datetime: + end_date_time: + name: End time + description: The date and time the event should end. + example: "2022-03-22 22:00:00" + selector: + datetime: + start_date: + name: Start date + description: The date the all-day event should start. + example: "2022-03-22" + selector: + date: + end_date: + name: End date + description: The date the all-day event should end (exclusive). + example: "2022-03-23" + selector: + date: + in: + name: In + description: Days or weeks that you want to create the event in. + example: '"days": 2 or "weeks": 2' diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index dcbcfe44474..2cbf122da01 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -19,9 +19,9 @@ from gcal_sync.model import AccessRole, DateOrDatetime, Event from gcal_sync.store import ScopedCalendarStore from gcal_sync.sync import CalendarEventSyncManager from gcal_sync.timeline import Timeline -import voluptuous as vol from homeassistant.components.calendar import ( + CREATE_EVENT_SCHEMA, ENTITY_ID_FORMAT, EVENT_DESCRIPTION, EVENT_END, @@ -38,11 +38,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, PlatformNotReady -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - entity_registry as er, -) +from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -74,7 +70,6 @@ from .const import ( EVENT_IN_WEEKS, EVENT_START_DATE, EVENT_START_DATETIME, - EVENT_TYPES_CONF, FeatureAccess, ) @@ -95,41 +90,7 @@ OPAQUE = "opaque" # we need to strip when working with the frontend recurrence rule values RRULE_PREFIX = "RRULE:" -_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( @@ -544,9 +505,12 @@ class GoogleCalendarEntity( if rrule := kwargs.get(EVENT_RRULE): event.recurrence = [f"{RRULE_PREFIX}{rrule}"] - await cast( - CalendarSyncUpdateCoordinator, self.coordinator - ).sync.store_service.async_add_event(event) + try: + await cast( + CalendarSyncUpdateCoordinator, self.coordinator + ).sync.store_service.async_add_event(event) + except ApiException as err: + raise HomeAssistantError(f"Error while creating event: {str(err)}") from err await self.coordinator.async_refresh() async def async_delete_event( diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 38c17b15b04..e90fc3b279b 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -1,11 +1,18 @@ """The tests for the calendar component.""" + +from __future__ import annotations + from datetime import timedelta from http import HTTPStatus +from typing import Any from unittest.mock import patch import pytest +import voluptuous as vol from homeassistant.bootstrap import async_setup_component +from homeassistant.components.calendar import DOMAIN +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util @@ -157,3 +164,165 @@ async def test_unsupported_websocket(hass, hass_ws_client, payload, code): assert resp.get("id") == 1 assert resp.get("error") assert resp["error"].get("code") == code + + +async def test_unsupported_create_event_service(hass): + """Test unsupported service call.""" + + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match="does not support this service"): + await hass.services.async_call( + DOMAIN, + "create_event", + { + "start_date_time": "1997-07-14T17:00:00+00:00", + "end_date_time": "1997-07-15T04:00:00+00:00", + "summary": "Bastille Day Party", + }, + target={"entity_id": "calendar.calendar_1"}, + blocking=True, + ) + + +@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_create_event_service_invalid_params( + hass: HomeAssistant, + date_fields: dict[str, Any], + expected_error: type[Exception], + error_match: str | None, +): + """Test creating an event using the create_event service.""" + + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + + with pytest.raises(expected_error, match=error_match): + await hass.services.async_call( + "calendar", + "create_event", + { + "summary": "Bastille Day Party", + **date_fields, + }, + target={"entity_id": "calendar.calendar_1"}, + blocking=True, + ) diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 861f3d9cbdf..4436b9226ff 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -7,6 +7,7 @@ import http import time from typing import Any from unittest.mock import Mock, patch +import zoneinfo from aiohttp.client_exceptions import ClientError import pytest @@ -56,28 +57,36 @@ def assert_state(actual: State | None, expected: State | None) -> None: @pytest.fixture( params=[ ( + DOMAIN, SERVICE_ADD_EVENT, {"calendar_id": CALENDAR_ID}, None, ), ( + DOMAIN, + SERVICE_CREATE_EVENT, + {}, + {"entity_id": TEST_API_ENTITY}, + ), + ( + "calendar", SERVICE_CREATE_EVENT, {}, {"entity_id": TEST_API_ENTITY}, ), ], - ids=("add_event", "create_event"), + ids=("google.add_event", "google.create_event", "calendar.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 + (domain, service_call, data, target) = request.param async def call_service(params: dict[str, Any]) -> None: await hass.services.async_call( - DOMAIN, + domain, service_call, { **data, @@ -536,7 +545,7 @@ async def test_add_event_date_time( mock_events_list({}) assert await component_setup() - start_datetime = datetime.datetime.now() + start_datetime = datetime.datetime.now(tz=zoneinfo.ZoneInfo("America/Regina")) delta = datetime.timedelta(days=3, hours=3) end_datetime = start_datetime + delta diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 3cbbf6a19ad..21aa39a4d7a 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -960,3 +960,39 @@ async def test_update_invalid_event_id( assert not resp.get("success") assert "error" in resp assert resp.get("error").get("code") == "failed" + + +async def test_create_event_service( + hass: HomeAssistant, setup_integration: None, get_events: GetEventsFn +): + """Test creating an event using the create_event service.""" + + await hass.services.async_call( + "calendar", + "create_event", + { + "start_date_time": "1997-07-14T17:00:00+00:00", + "end_date_time": "1997-07-15T04:00:00+00:00", + "summary": "Bastille Day Party", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + events = await get_events("1997-07-14T00:00:00Z", "1997-07-16T00:00:00Z") + 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"}, + } + ] + + events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T18:00:00Z") + 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"}, + } + ]