diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 0b1c37cea5f..594964c129c 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -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, ) diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index fc224d20685..87aec3a6f5d 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -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 diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 875d5bf8c13..d58932ce898 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -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=[ diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 8b544a828e9..7d59d80687e 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -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 + )