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
parent
3fb80712be
commit
fb8cbc2e93
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue