Add service to create calendar events (#85805)

This commit is contained in:
Allen Porter 2023-01-25 03:43:50 -08:00 committed by GitHub
parent 5f1edbccd1
commit 7ff1265b10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 383 additions and 52 deletions

View File

@ -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.components.websocket_api.connection import ActiveConnection
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.config_validation import ( # noqa: F401
@ -37,15 +37,26 @@ from .const import (
CONF_EVENT, CONF_EVENT,
EVENT_DESCRIPTION, EVENT_DESCRIPTION,
EVENT_END, EVENT_END,
EVENT_END_DATE,
EVENT_END_DATETIME,
EVENT_IN,
EVENT_IN_DAYS,
EVENT_IN_WEEKS,
EVENT_RECURRENCE_ID, EVENT_RECURRENCE_ID,
EVENT_RECURRENCE_RANGE, EVENT_RECURRENCE_RANGE,
EVENT_RRULE, EVENT_RRULE,
EVENT_START, EVENT_START,
EVENT_START_DATE,
EVENT_START_DATETIME,
EVENT_SUMMARY, EVENT_SUMMARY,
EVENT_TIME_FIELDS,
EVENT_TYPES,
EVENT_UID, EVENT_UID,
CalendarEntityFeature, CalendarEntityFeature,
) )
# mypy: disallow-any-generics
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "calendar" DOMAIN = "calendar"
@ -55,8 +66,39 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60)
# Don't support rrules more often than daily # Don't support rrules more often than daily
VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"} VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"}
CREATE_EVENT_SERVICE = "create_event"
# mypy: disallow-any-generics 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: 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_delete)
websocket_api.async_register_command(hass, handle_calendar_event_update) 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) await component.async_setup(config)
return True return True
@ -569,3 +618,43 @@ async def handle_calendar_event_update(
connection.send_error(msg["id"], "failed", str(ex)) connection.send_error(msg["id"], "failed", str(ex))
else: else:
connection.send_result(msg["id"]) 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)

View File

@ -23,3 +23,20 @@ EVENT_LOCATION = "location"
EVENT_RECURRENCE_ID = "recurrence_id" EVENT_RECURRENCE_ID = "recurrence_id"
EVENT_RECURRENCE_RANGE = "recurrence_range" EVENT_RECURRENCE_RANGE = "recurrence_range"
EVENT_RRULE = "rrule" 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"

View File

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

View File

@ -19,9 +19,9 @@ from gcal_sync.model import AccessRole, DateOrDatetime, Event
from gcal_sync.store import ScopedCalendarStore from gcal_sync.store import ScopedCalendarStore
from gcal_sync.sync import CalendarEventSyncManager from gcal_sync.sync import CalendarEventSyncManager
from gcal_sync.timeline import Timeline from gcal_sync.timeline import Timeline
import voluptuous as vol
from homeassistant.components.calendar import ( from homeassistant.components.calendar import (
CREATE_EVENT_SCHEMA,
ENTITY_ID_FORMAT, ENTITY_ID_FORMAT,
EVENT_DESCRIPTION, EVENT_DESCRIPTION,
EVENT_END, 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.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import ( from homeassistant.helpers import entity_platform, entity_registry as er
config_validation as cv,
entity_platform,
entity_registry as er,
)
from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import (
@ -74,7 +70,6 @@ from .const import (
EVENT_IN_WEEKS, EVENT_IN_WEEKS,
EVENT_START_DATE, EVENT_START_DATE,
EVENT_START_DATETIME, EVENT_START_DATETIME,
EVENT_TYPES_CONF,
FeatureAccess, FeatureAccess,
) )
@ -95,41 +90,7 @@ OPAQUE = "opaque"
# we need to strip when working with the frontend recurrence rule values # we need to strip when working with the frontend recurrence rule values
RRULE_PREFIX = "RRULE:" 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" 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( async def async_setup_entry(
@ -544,9 +505,12 @@ class GoogleCalendarEntity(
if rrule := kwargs.get(EVENT_RRULE): if rrule := kwargs.get(EVENT_RRULE):
event.recurrence = [f"{RRULE_PREFIX}{rrule}"] event.recurrence = [f"{RRULE_PREFIX}{rrule}"]
await cast( try:
CalendarSyncUpdateCoordinator, self.coordinator await cast(
).sync.store_service.async_add_event(event) 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() await self.coordinator.async_refresh()
async def async_delete_event( async def async_delete_event(

View File

@ -1,11 +1,18 @@
"""The tests for the calendar component.""" """The tests for the calendar component."""
from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus from http import HTTPStatus
from typing import Any
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
import voluptuous as vol
from homeassistant.bootstrap import async_setup_component from homeassistant.bootstrap import async_setup_component
from homeassistant.components.calendar import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import homeassistant.util.dt as dt_util 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("id") == 1
assert resp.get("error") assert resp.get("error")
assert resp["error"].get("code") == code 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,
)

View File

@ -7,6 +7,7 @@ import http
import time import time
from typing import Any from typing import Any
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import zoneinfo
from aiohttp.client_exceptions import ClientError from aiohttp.client_exceptions import ClientError
import pytest import pytest
@ -56,28 +57,36 @@ def assert_state(actual: State | None, expected: State | None) -> None:
@pytest.fixture( @pytest.fixture(
params=[ params=[
( (
DOMAIN,
SERVICE_ADD_EVENT, SERVICE_ADD_EVENT,
{"calendar_id": CALENDAR_ID}, {"calendar_id": CALENDAR_ID},
None, None,
), ),
( (
DOMAIN,
SERVICE_CREATE_EVENT,
{},
{"entity_id": TEST_API_ENTITY},
),
(
"calendar",
SERVICE_CREATE_EVENT, SERVICE_CREATE_EVENT,
{}, {},
{"entity_id": TEST_API_ENTITY}, {"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( def add_event_call_service(
hass: HomeAssistant, hass: HomeAssistant,
request: Any, request: Any,
) -> Callable[dict[str, Any], Awaitable[None]]: ) -> Callable[dict[str, Any], Awaitable[None]]:
"""Fixture for calling the add or create event service.""" """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: async def call_service(params: dict[str, Any]) -> None:
await hass.services.async_call( await hass.services.async_call(
DOMAIN, domain,
service_call, service_call,
{ {
**data, **data,
@ -536,7 +545,7 @@ async def test_add_event_date_time(
mock_events_list({}) mock_events_list({})
assert await component_setup() 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) delta = datetime.timedelta(days=3, hours=3)
end_datetime = start_datetime + delta end_datetime = start_datetime + delta

View File

@ -960,3 +960,39 @@ async def test_update_invalid_event_id(
assert not resp.get("success") assert not resp.get("success")
assert "error" in resp assert "error" in resp
assert resp.get("error").get("code") == "failed" 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"},
}
]