Fix CalDAV recurring events (#31805)

* Fix CalDAV parsing of recurring events

Some CaDAV servers (see: SOGo) return the original event that contains
the recurrence rules. The CalDAV calendar component sorts and filters
events based on their start and end dates, and was failing to properly
show recurring events based on these recurrence rules.

This this change checks if an event has recurrence rules and changes the
start/end dates of the event to today if the event is set to occur
today. This allows the rest of the component logic to function properly.

* Use date from nextmost occurence

* Adding unit tests

* Add endless event unit test

* Create new vevent for each event recurrence today

* Remove redundant unit test

* Add timezone to events that have none

Python cannot compare them otherwise.

* Simplify code, add comments & guard clause

* Add test for recurring all day event

* Account for all-day events

* Remove redundant code

* Remove redundant code

* Remove unnecessary deepcopy

* Add hourly recurring tests

* Add tests for hourly repeating event

* Fix unit test

* Use event.copy()
pull/31875/head
Philip Rosenberg-Watt 2020-02-15 20:31:36 -07:00 committed by GitHub
parent 3fb80712be
commit fb8cbc2e93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 350 additions and 13 deletions

View File

@ -193,27 +193,50 @@ class WebDavCalendarData:
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data."""
start_of_today = dt.start_of_local_day()
start_of_tomorrow = dt.start_of_local_day() + timedelta(days=1)
# We have to retrieve the results for the whole day as the server
# won't return events that have already started
results = self.calendar.date_search(
dt.start_of_local_day(), dt.start_of_local_day() + timedelta(days=1)
)
results = self.calendar.date_search(start_of_today, start_of_tomorrow)
# Create new events for each recurrence of an event that happens today.
# For recurring events, some servers return the original event with recurrence rules
# and they would not be properly parsed using their original start/end dates.
new_events = []
for event in results:
vevent = event.instance.vevent
for start_dt in vevent.getrruleset() or []:
_start_of_today = start_of_today
_start_of_tomorrow = start_of_tomorrow
if self.is_all_day(vevent):
start_dt = start_dt.date()
_start_of_today = _start_of_today.date()
_start_of_tomorrow = _start_of_tomorrow.date()
if _start_of_today <= start_dt < _start_of_tomorrow:
new_event = event.copy()
new_vevent = new_event.instance.vevent
if hasattr(new_vevent, "dtend"):
dur = new_vevent.dtend.value - new_vevent.dtstart.value
new_vevent.dtend.value = start_dt + dur
new_vevent.dtstart.value = start_dt
new_events.append(new_event)
elif _start_of_tomorrow <= start_dt:
break
vevents = [event.instance.vevent for event in results + new_events]
# dtstart can be a date or datetime depending if the event lasts a
# whole day. Convert everything to datetime to be able to sort it
results.sort(key=lambda x: self.to_datetime(x.instance.vevent.dtstart.value))
vevents.sort(key=lambda x: self.to_datetime(x.dtstart.value))
vevent = next(
(
event.instance.vevent
for event in results
vevent
for vevent in vevents
if (
self.is_matching(event.instance.vevent, self.search)
and (
not self.is_all_day(event.instance.vevent)
or self.include_all_day
)
and not self.is_over(event.instance.vevent)
self.is_matching(vevent, self.search)
and (not self.is_all_day(vevent) or self.include_all_day)
and not self.is_over(vevent)
)
),
None,
@ -223,7 +246,7 @@ class WebDavCalendarData:
if vevent is None:
_LOGGER.debug(
"No matching event found in the %d results for %s",
len(results),
len(vevents),
self.calendar.name,
)
self.event = None

View File

@ -124,6 +124,96 @@ LOCATION:Hamburg
DESCRIPTION:What a day
END:VEVENT
END:VCALENDAR
""",
"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:9
DTSTAMP:20171125T000000Z
DTSTART:20171027T220000Z
DTEND:20171027T223000Z
SUMMARY:This is a recurring event
LOCATION:Hamburg
DESCRIPTION:Every day for a while
RRULE:FREQ=DAILY;UNTIL=20171227T215959
END:VEVENT
END:VCALENDAR
""",
"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:10
DTSTAMP:20171125T000000Z
DTSTART:20171027T230000Z
DURATION:PT30M
SUMMARY:This is a recurring event with a duration
LOCATION:Hamburg
DESCRIPTION:Every day for a while as well
RRULE:FREQ=DAILY;UNTIL=20171227T215959
END:VEVENT
END:VCALENDAR
""",
"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:11
DTSTAMP:20171125T000000Z
DTSTART:20171027T233000Z
DTEND:20171027T235959Z
SUMMARY:This is a recurring event that has ended
LOCATION:Hamburg
DESCRIPTION:Every day for a while
RRULE:FREQ=DAILY;UNTIL=20171127T225959
END:VEVENT
END:VCALENDAR
""",
"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:12
DTSTAMP:20171125T000000Z
DTSTART:20171027T234500Z
DTEND:20171027T235959Z
SUMMARY:This is a recurring event that never ends
LOCATION:Hamburg
DESCRIPTION:Every day forever
RRULE:FREQ=DAILY
END:VEVENT
END:VCALENDAR
""",
"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Global Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:13
DTSTAMP:20161125T000000Z
DTSTART:20161127
DTEND:20161128
SUMMARY:This is a recurring all day event
LOCATION:Hamburg
DESCRIPTION:Groundhog Day
RRULE:FREQ=DAILY;COUNT=100
END:VEVENT
END:VCALENDAR
""",
"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Global Corp.//CalDAV Client//EN
BEGIN:VEVENT
UID:14
DTSTAMP:20151125T000000Z
DTSTART:20151127T000000Z
DTEND:20151127T003000Z
SUMMARY:This is an hourly recurring event
LOCATION:Hamburg
DESCRIPTION:The bell tolls for thee
RRULE:FREQ=HOURLY;INTERVAL=1;COUNT=12
END:VEVENT
END:VCALENDAR
""",
]
@ -461,3 +551,227 @@ async def test_all_day_event_returned(mock_now, hass, calendar):
"location": "Hamburg",
"description": "What a beautiful day",
}
@patch("homeassistant.util.dt.now", return_value=_local_datetime(21, 45))
async def test_event_rrule(mock_now, hass, calendar):
"""Test that the future recurring event is returned."""
assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
await hass.async_block_till_done()
state = hass.states.get("calendar.private")
assert state.name == calendar.name
assert state.state == STATE_OFF
assert dict(state.attributes) == {
"friendly_name": "Private",
"message": "This is a recurring event",
"all_day": False,
"offset_reached": False,
"start_time": "2017-11-27 22:00:00",
"end_time": "2017-11-27 22:30:00",
"location": "Hamburg",
"description": "Every day for a while",
}
@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 15))
async def test_event_rrule_ongoing(mock_now, hass, calendar):
"""Test that the current recurring event is returned."""
assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
await hass.async_block_till_done()
state = hass.states.get("calendar.private")
assert state.name == calendar.name
assert state.state == STATE_ON
assert dict(state.attributes) == {
"friendly_name": "Private",
"message": "This is a recurring event",
"all_day": False,
"offset_reached": False,
"start_time": "2017-11-27 22:00:00",
"end_time": "2017-11-27 22:30:00",
"location": "Hamburg",
"description": "Every day for a while",
}
@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 45))
async def test_event_rrule_duration(mock_now, hass, calendar):
"""Test that the future recurring event is returned."""
assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
await hass.async_block_till_done()
state = hass.states.get("calendar.private")
assert state.name == calendar.name
assert state.state == STATE_OFF
assert dict(state.attributes) == {
"friendly_name": "Private",
"message": "This is a recurring event with a duration",
"all_day": False,
"offset_reached": False,
"start_time": "2017-11-27 23:00:00",
"end_time": "2017-11-27 23:30:00",
"location": "Hamburg",
"description": "Every day for a while as well",
}
@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 15))
async def test_event_rrule_duration_ongoing(mock_now, hass, calendar):
"""Test that the ongoing recurring event is returned."""
assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
await hass.async_block_till_done()
state = hass.states.get("calendar.private")
assert state.name == calendar.name
assert state.state == STATE_ON
assert dict(state.attributes) == {
"friendly_name": "Private",
"message": "This is a recurring event with a duration",
"all_day": False,
"offset_reached": False,
"start_time": "2017-11-27 23:00:00",
"end_time": "2017-11-27 23:30:00",
"location": "Hamburg",
"description": "Every day for a while as well",
}
@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 37))
async def test_event_rrule_endless(mock_now, hass, calendar):
"""Test that the endless recurring event is returned."""
assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
await hass.async_block_till_done()
state = hass.states.get("calendar.private")
assert state.name == calendar.name
assert state.state == STATE_OFF
assert dict(state.attributes) == {
"friendly_name": "Private",
"message": "This is a recurring event that never ends",
"all_day": False,
"offset_reached": False,
"start_time": "2017-11-27 23:45:00",
"end_time": "2017-11-27 23:59:59",
"location": "Hamburg",
"description": "Every day forever",
}
@patch(
"homeassistant.util.dt.now",
return_value=dt.as_local(datetime.datetime(2016, 12, 1, 17, 30)),
)
async def test_event_rrule_all_day(mock_now, hass, calendar):
"""Test that the recurring all day event is returned."""
config = dict(CALDAV_CONFIG)
config["custom_calendars"] = [
{"name": "Private", "calendar": "Private", "search": ".*"}
]
assert await async_setup_component(hass, "calendar", {"calendar": config})
await hass.async_block_till_done()
state = hass.states.get("calendar.private_private")
assert state.name == calendar.name
assert state.state == STATE_ON
assert dict(state.attributes) == {
"friendly_name": "Private",
"message": "This is a recurring all day event",
"all_day": True,
"offset_reached": False,
"start_time": "2016-12-01 00:00:00",
"end_time": "2016-12-02 00:00:00",
"location": "Hamburg",
"description": "Groundhog Day",
}
@patch(
"homeassistant.util.dt.now",
return_value=dt.as_local(datetime.datetime(2015, 11, 27, 0, 15)),
)
async def test_event_rrule_hourly_on_first(mock_now, hass, calendar):
"""Test that the endless recurring event is returned."""
assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
await hass.async_block_till_done()
state = hass.states.get("calendar.private")
assert state.name == calendar.name
assert state.state == STATE_ON
assert dict(state.attributes) == {
"friendly_name": "Private",
"message": "This is an hourly recurring event",
"all_day": False,
"offset_reached": False,
"start_time": "2015-11-27 00:00:00",
"end_time": "2015-11-27 00:30:00",
"location": "Hamburg",
"description": "The bell tolls for thee",
}
@patch(
"homeassistant.util.dt.now",
return_value=dt.as_local(datetime.datetime(2015, 11, 27, 11, 15)),
)
async def test_event_rrule_hourly_on_last(mock_now, hass, calendar):
"""Test that the endless recurring event is returned."""
assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
await hass.async_block_till_done()
state = hass.states.get("calendar.private")
assert state.name == calendar.name
assert state.state == STATE_ON
assert dict(state.attributes) == {
"friendly_name": "Private",
"message": "This is an hourly recurring event",
"all_day": False,
"offset_reached": False,
"start_time": "2015-11-27 11:00:00",
"end_time": "2015-11-27 11:30:00",
"location": "Hamburg",
"description": "The bell tolls for thee",
}
@patch(
"homeassistant.util.dt.now",
return_value=dt.as_local(datetime.datetime(2015, 11, 27, 0, 45)),
)
async def test_event_rrule_hourly_off_first(mock_now, hass, calendar):
"""Test that the endless recurring event is returned."""
assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
await hass.async_block_till_done()
state = hass.states.get("calendar.private")
assert state.name == calendar.name
assert state.state == STATE_OFF
@patch(
"homeassistant.util.dt.now",
return_value=dt.as_local(datetime.datetime(2015, 11, 27, 11, 45)),
)
async def test_event_rrule_hourly_off_last(mock_now, hass, calendar):
"""Test that the endless recurring event is returned."""
assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
await hass.async_block_till_done()
state = hass.states.get("calendar.private")
assert state.name == calendar.name
assert state.state == STATE_OFF
@patch(
"homeassistant.util.dt.now",
return_value=dt.as_local(datetime.datetime(2015, 11, 27, 12, 15)),
)
async def test_event_rrule_hourly_ended(mock_now, hass, calendar):
"""Test that the endless recurring event is returned."""
assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
await hass.async_block_till_done()
state = hass.states.get("calendar.private")
assert state.name == calendar.name
assert state.state == STATE_OFF