Relax calendar event validation to allow existing zero duration events (#91129)

Relax event valudation to allow existing zero duration events
pull/91169/head
Allen Porter 2023-04-10 07:04:42 -07:00 committed by GitHub
parent 6b9d748529
commit 14b95ffe3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 126 additions and 14 deletions

View File

@ -67,6 +67,13 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60)
# Don't support rrules more often than daily
VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"}
# Ensure events created in Home Assistant have a positive duration
MIN_NEW_EVENT_DURATION = datetime.timedelta(seconds=1)
# Events must have a non-negative duration e.g. Google Calendar can create zero
# duration events in the UI.
MIN_EVENT_DURATION = datetime.timedelta(seconds=0)
def _has_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]:
"""Assert that all datetime values have a timezone."""
@ -116,17 +123,38 @@ def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]
return validate
def _has_duration(
start_key: str, end_key: str
def _has_min_duration(
start_key: str, end_key: str, min_duration: datetime.timedelta
) -> Callable[[dict[str, Any]], dict[str, Any]]:
"""Verify that the time span between start and end is positive."""
"""Verify that the time span between start and end has a minimum duration."""
def validate(obj: dict[str, Any]) -> dict[str, Any]:
"""Test that all keys in the dict are in order."""
if (start := obj.get(start_key)) and (end := obj.get(end_key)):
duration = end - start
if duration.total_seconds() <= 0:
raise vol.Invalid(f"Expected positive event duration ({start}, {end})")
if duration < min_duration:
raise vol.Invalid(
f"Expected minimum event duration of {min_duration} ({start}, {end})"
)
return obj
return validate
def _has_all_day_event_duration(
start_key: str,
end_key: str,
) -> Callable[[dict[str, Any]], dict[str, Any]]:
"""Modify all day events to have a duration of one day."""
def validate(obj: dict[str, Any]) -> dict[str, Any]:
if (
(start := obj.get(start_key))
and (end := obj.get(end_key))
and not isinstance(start, datetime.datetime)
and not isinstance(end, datetime.datetime)
and start == end
):
obj[end_key] = start + datetime.timedelta(days=1)
return obj
return validate
@ -204,8 +232,8 @@ CREATE_EVENT_SCHEMA = vol.All(
),
_has_consistent_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME),
_as_local_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME),
_has_duration(EVENT_START_DATE, EVENT_END_DATE),
_has_duration(EVENT_START_DATETIME, EVENT_END_DATETIME),
_has_min_duration(EVENT_START_DATE, EVENT_END_DATE, MIN_NEW_EVENT_DURATION),
_has_min_duration(EVENT_START_DATETIME, EVENT_END_DATETIME, MIN_NEW_EVENT_DURATION),
)
WEBSOCKET_EVENT_SCHEMA = vol.Schema(
@ -221,7 +249,7 @@ WEBSOCKET_EVENT_SCHEMA = vol.Schema(
_has_same_type(EVENT_START, EVENT_END),
_has_consistent_timezone(EVENT_START, EVENT_END),
_as_local_timezone(EVENT_START, EVENT_END),
_has_duration(EVENT_START, EVENT_END),
_has_min_duration(EVENT_START, EVENT_END, MIN_NEW_EVENT_DURATION),
)
)
@ -238,7 +266,8 @@ CALENDAR_EVENT_SCHEMA = vol.Schema(
_has_timezone("start", "end"),
_has_consistent_timezone("start", "end"),
_as_local_timezone("start", "end"),
_has_duration("start", "end"),
_has_min_duration("start", "end", MIN_EVENT_DURATION),
_has_all_day_event_duration("start", "end"),
),
extra=vol.ALLOW_EXTRA,
)

View File

@ -254,6 +254,32 @@ DTEND;TZID=Europe/London:20221127T003000
SUMMARY:Event with a provided Timezone
END:VEVENT
END:VCALENDAR
""",
"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Global Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:16
DTSTAMP:20171125T000000Z
DTSTART:20171127
DTEND:20171128
SUMMARY:All day event with same start and end
LOCATION:Hamburg
END:VEVENT
END:VCALENDAR
""",
"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Global Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:17
DTSTAMP:20171125T000000Z
DTSTART:20171127T010000
DTEND:20171127T010000
SUMMARY:Event with no duration
LOCATION:Hamburg
END:VEVENT
END:VCALENDAR
""",
]
@ -1001,7 +1027,7 @@ async def test_get_events(hass: HomeAssistant, calendar, get_api_events) -> None
await hass.async_block_till_done()
events = await get_api_events("calendar.private")
assert len(events) == 16
assert len(events) == 18
assert calendar.call

View File

@ -324,7 +324,7 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None:
"end_date_time": "2022-04-01T06:00:00",
},
vol.error.MultipleInvalid,
"Expected positive event duration",
"Expected minimum event duration",
),
(
{
@ -332,7 +332,7 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None:
"end_date": "2022-04-01",
},
vol.error.MultipleInvalid,
"Expected positive event duration",
"Expected minimum event duration",
),
(
{
@ -340,7 +340,7 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None:
"end_date": "2022-04-01",
},
vol.error.MultipleInvalid,
"Expected positive event duration",
"Expected minimum event duration",
),
],
ids=[

View File

@ -1238,3 +1238,60 @@ async def test_reader_in_progress_event(
"location": event["location"],
"description": event["description"],
}
async def test_all_day_event_without_duration(
hass: HomeAssistant, mock_events_list_items, component_setup
) -> None:
"""Test that an all day event without a duration is adjusted to have a duration of one day."""
week_from_today = dt_util.now().date() + datetime.timedelta(days=7)
event = {
**TEST_EVENT,
"start": {"date": week_from_today.isoformat()},
"end": {"date": week_from_today.isoformat()},
}
mock_events_list_items([event])
assert await component_setup()
expected_end_event = week_from_today + datetime.timedelta(days=1)
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": expected_end_event.strftime(DATE_STR_FORMAT),
"location": event["location"],
"description": event["description"],
"supported_features": 3,
}
async def test_event_without_duration(
hass: HomeAssistant, mock_events_list_items, component_setup
) -> None:
"""Google calendar UI allows creating events without a duration."""
one_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30)
event = {
**TEST_EVENT,
"start": {"dateTime": one_hour_from_now.isoformat()},
"end": {"dateTime": one_hour_from_now.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
# Confirm the event is parsed successfully, but we don't assert on the
# specific end date as the client library may adjust it
assert state.attributes.get("message") == event["summary"]
assert state.attributes.get("start_time") == one_hour_from_now.strftime(
DATE_STR_FORMAT
)