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.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)

View File

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

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.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(

View File

@ -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,
)

View File

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

View File

@ -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"},
}
]