Relax calendar event validation to allow existing zero duration events (#91129)
Relax event valudation to allow existing zero duration events
This commit is contained in:
parent
6b9d748529
commit
14b95ffe3a
4 changed files with 126 additions and 14 deletions
|
@ -67,6 +67,13 @@ 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"}
|
||||||
|
|
||||||
|
# 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]]:
|
def _has_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]:
|
||||||
"""Assert that all datetime values have a timezone."""
|
"""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
|
return validate
|
||||||
|
|
||||||
|
|
||||||
def _has_duration(
|
def _has_min_duration(
|
||||||
start_key: str, end_key: str
|
start_key: str, end_key: str, min_duration: datetime.timedelta
|
||||||
) -> Callable[[dict[str, Any]], dict[str, Any]]:
|
) -> 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]:
|
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)):
|
if (start := obj.get(start_key)) and (end := obj.get(end_key)):
|
||||||
duration = end - start
|
duration = end - start
|
||||||
if duration.total_seconds() <= 0:
|
if duration < min_duration:
|
||||||
raise vol.Invalid(f"Expected positive event duration ({start}, {end})")
|
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 obj
|
||||||
|
|
||||||
return validate
|
return validate
|
||||||
|
@ -204,8 +232,8 @@ CREATE_EVENT_SCHEMA = vol.All(
|
||||||
),
|
),
|
||||||
_has_consistent_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME),
|
_has_consistent_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME),
|
||||||
_as_local_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_min_duration(EVENT_START_DATE, EVENT_END_DATE, MIN_NEW_EVENT_DURATION),
|
||||||
_has_duration(EVENT_START_DATETIME, EVENT_END_DATETIME),
|
_has_min_duration(EVENT_START_DATETIME, EVENT_END_DATETIME, MIN_NEW_EVENT_DURATION),
|
||||||
)
|
)
|
||||||
|
|
||||||
WEBSOCKET_EVENT_SCHEMA = vol.Schema(
|
WEBSOCKET_EVENT_SCHEMA = vol.Schema(
|
||||||
|
@ -221,7 +249,7 @@ WEBSOCKET_EVENT_SCHEMA = vol.Schema(
|
||||||
_has_same_type(EVENT_START, EVENT_END),
|
_has_same_type(EVENT_START, EVENT_END),
|
||||||
_has_consistent_timezone(EVENT_START, EVENT_END),
|
_has_consistent_timezone(EVENT_START, EVENT_END),
|
||||||
_as_local_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_timezone("start", "end"),
|
||||||
_has_consistent_timezone("start", "end"),
|
_has_consistent_timezone("start", "end"),
|
||||||
_as_local_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,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
|
@ -254,6 +254,32 @@ DTEND;TZID=Europe/London:20221127T003000
|
||||||
SUMMARY:Event with a provided Timezone
|
SUMMARY:Event with a provided Timezone
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR
|
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()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
events = await get_api_events("calendar.private")
|
events = await get_api_events("calendar.private")
|
||||||
assert len(events) == 16
|
assert len(events) == 18
|
||||||
assert calendar.call
|
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",
|
"end_date_time": "2022-04-01T06:00:00",
|
||||||
},
|
},
|
||||||
vol.error.MultipleInvalid,
|
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",
|
"end_date": "2022-04-01",
|
||||||
},
|
},
|
||||||
vol.error.MultipleInvalid,
|
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",
|
"end_date": "2022-04-01",
|
||||||
},
|
},
|
||||||
vol.error.MultipleInvalid,
|
vol.error.MultipleInvalid,
|
||||||
"Expected positive event duration",
|
"Expected minimum event duration",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
ids=[
|
ids=[
|
||||||
|
|
|
@ -1238,3 +1238,60 @@ async def test_reader_in_progress_event(
|
||||||
"location": event["location"],
|
"location": event["location"],
|
||||||
"description": event["description"],
|
"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…
Add table
Reference in a new issue