Relax calendar event validation to allow existing zero duration events (#91129)
Relax event valudation to allow existing zero duration eventspull/91169/head
parent
6b9d748529
commit
14b95ffe3a
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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=[
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue