Expand taking timezone into account

This commit is contained in:
Pieter Hijma 2024-11-07 11:03:24 +01:00
parent bf77844d34
commit cfc1e94ad8
3 changed files with 131 additions and 89 deletions

View file

@ -24,8 +24,8 @@ import posixpath
import socket
import xml.etree.ElementTree as ET
from http import client
from typing import (Any, Callable, Iterable, Iterator, List, Optional,
Sequence, Tuple, Union)
from typing import (Callable, Iterable, Iterator, List, Optional, Sequence,
Tuple, Union)
from urllib.parse import unquote, urlparse
import vobject
@ -296,26 +296,45 @@ def _expand(
start: datetime.datetime,
end: datetime.datetime,
) -> ET.Element:
dt_format = '%Y%m%dT%H%M%SZ'
vevent_component: vobject.base.Component = copy.copy(item.vobject_item)
if type(item.vobject_item.vevent.dtstart.value) is datetime.date:
# If an event comes to us with a dt_start specified as a date
# Split the vevents included in the component into one that contains the
# recurrence information and others that contain a recurrence id to
# override instances.
vevent_recurrence, vevents_overridden = _split_overridden_vevents(vevent_component)
dt_format = '%Y%m%dT%H%M%SZ'
all_day_event = False
if type(vevent_recurrence.dtstart.value) is datetime.date:
# If an event comes to us with a dtstart specified as a date
# then in the response we return the date, not datetime
dt_format = '%Y%m%d'
all_day_event = True
# In case of dates, we need to remove timezone information since
# rruleset.between computes with datetimes without timezone information
start = start.replace(tzinfo=None)
end = end.replace(tzinfo=None)
for vevent in vevents_overridden:
_strip_single_event(vevent, dt_format)
duration = None
if hasattr(item.vobject_item.vevent, "dtend"):
duration = item.vobject_item.vevent.dtend.value - item.vobject_item.vevent.dtstart.value
if hasattr(vevent_recurrence, "dtend"):
duration = vevent_recurrence.dtend.value - vevent_recurrence.dtstart.value
expanded_item, rruleset = _make_vobject_expanded_item(item, dt_format)
rruleset = None
if hasattr(vevent_recurrence, 'rrule'):
rruleset = vevent_recurrence.getrruleset()
if rruleset:
# This function uses datetimes internally without timezone info for dates
recurrences = rruleset.between(start, end, inc=True)
expanded: vobject.base.Component = copy.copy(expanded_item.vobject_item)
vevent_recurrence, vevents_overridden = _split_overridden_vevents(expanded, dt_format)
_strip_component(vevent_component)
_strip_single_event(vevent_recurrence, dt_format)
is_expanded_filled: bool = False
is_component_filled: bool = False
i_overridden = 0
for recurrence_dt in recurrences:
@ -323,30 +342,34 @@ def _expand(
i_overridden, vevent = _find_overridden(i_overridden, vevents_overridden, recurrence_utc, dt_format)
if not vevent:
# We did not find an overridden instance, so create a new one
vevent = copy.deepcopy(vevent_recurrence)
# For all day events, the system timezone may influence the
# results, so use recurrence_dt
recurrence_id = recurrence_dt if all_day_event else recurrence_utc
vevent.recurrence_id = ContentLine(
name='RECURRENCE-ID',
value=recurrence_utc.strftime(dt_format), params={}
value=recurrence_id, params={}
)
_convert_to_utc(vevent, 'recurrence_id', dt_format)
vevent.dtstart = ContentLine(
name='DTSTART',
value=recurrence_utc.strftime(dt_format), params={}
value=recurrence_id.strftime(dt_format), params={}
)
if duration:
vevent.dtend = ContentLine(
name='DTEND',
value=(recurrence_utc + duration).strftime(dt_format), params={}
value=(recurrence_id + duration).strftime(dt_format), params={}
)
if is_expanded_filled is False:
expanded.vevent = vevent
is_expanded_filled = True
if not is_component_filled:
vevent_component.vevent = vevent
is_component_filled = True
else:
expanded.add(vevent)
vevent_component.add(vevent)
element.text = expanded.serialize()
else:
element.text = expanded_item.vobject_item.serialize()
element.text = vevent_component.serialize()
return element
@ -374,76 +397,37 @@ def _convert_to_utc(vevent: vobject.icalendar.RecurringComponent,
setattr(vevent, name_prop, ContentLine(name=prop.name, value=prop.value.strftime(dt_format), params=[]))
def _make_vobject_expanded_item(
item: radicale_item.Item,
dt_format: str,
) -> Tuple[radicale_item.Item, Optional[Any]]:
# https://www.rfc-editor.org/rfc/rfc4791#section-9.6.5
# The returned calendar components MUST NOT use recurrence
# properties (i.e., EXDATE, EXRULE, RDATE, and RRULE) and MUST NOT
# have reference to or include VTIMEZONE components. Date and local
# time with reference to time zone information MUST be converted
# into date with UTC time.
def _strip_single_event(vevent: vobject.icalendar.RecurringComponent, dt_format: str) -> None:
_convert_timezone(vevent, 'dtstart', 'DTSTART')
_convert_timezone(vevent, 'dtend', 'DTEND')
_convert_timezone(vevent, 'recurrence_id', 'RECURRENCE-ID')
item = copy.copy(item)
vevent = item.vobject_item.vevent
# There is something strange behaviour during serialization native datetime, so converting manually
_convert_to_utc(vevent, 'dtstart', dt_format)
_convert_to_utc(vevent, 'dtend', dt_format)
_convert_to_utc(vevent, 'recurrence_id', dt_format)
if type(vevent.dtstart.value) is datetime.date:
start_utc = datetime.datetime.fromordinal(
vevent.dtstart.value.toordinal()
).replace(tzinfo=datetime.timezone.utc)
else:
start_utc = vevent.dtstart.value.astimezone(datetime.timezone.utc)
try:
delattr(vevent, 'rrule')
delattr(vevent, 'exdate')
delattr(vevent, 'exrule')
delattr(vevent, 'rdate')
except AttributeError:
pass
vevent.dtstart = ContentLine(name='DTSTART', value=start_utc, params=[])
dt_end = getattr(vevent, 'dtend', None)
if dt_end is not None:
if type(vevent.dtend.value) is datetime.date:
end_utc = datetime.datetime.fromordinal(
dt_end.value.toordinal()
).replace(tzinfo=datetime.timezone.utc)
else:
end_utc = dt_end.value.astimezone(datetime.timezone.utc)
def _strip_component(vevent: vobject.base.Component) -> None:
timezones_to_remove = []
for component in vevent.components():
if component.name == 'VTIMEZONE':
timezones_to_remove.append(component)
vevent.dtend = ContentLine(name='DTEND', value=end_utc, params={})
rruleset = None
for i, vevent in enumerate(item.vobject_item.vevent_list):
_convert_timezone(vevent, 'dtstart', 'DTSTART')
_convert_timezone(vevent, 'dtend', 'DTEND')
_convert_timezone(vevent, 'recurrence_id', 'RECURRENCE-ID')
if hasattr(vevent, 'rrule'):
rruleset = vevent.getrruleset()
# There is something strange behaviour during serialization native datetime, so converting manually
_convert_to_utc(vevent, 'dtstart', dt_format)
_convert_to_utc(vevent, 'dtend', dt_format)
_convert_to_utc(vevent, 'recurrence_id', dt_format)
timezones_to_remove = []
for component in item.vobject_item.components():
if component.name == 'VTIMEZONE':
timezones_to_remove.append(component)
for timezone in timezones_to_remove:
item.vobject_item.remove(timezone)
try:
delattr(item.vobject_item.vevent_list[i], 'rrule')
delattr(item.vobject_item.vevent_list[i], 'exdate')
delattr(item.vobject_item.vevent_list[i], 'exrule')
delattr(item.vobject_item.vevent_list[i], 'rdate')
except AttributeError:
pass
return item, rruleset
for timezone in timezones_to_remove:
vevent.remove(timezone)
def _split_overridden_vevents(
component: vobject.base.Component,
dt_format: str
) -> Tuple[
vobject.icalendar.RecurringComponent,
List[vobject.icalendar.RecurringComponent]
@ -457,7 +441,7 @@ def _split_overridden_vevents(
elif vevent_recurrence:
raise ValueError(
f"component with UID {vevent.uid} "
f"has more than one vevent without a recurrence_id"
f"has more than one vevent with recurrence information"
)
else:
vevent_recurrence = vevent
@ -466,7 +450,7 @@ def _split_overridden_vevents(
return (
vevent_recurrence, sorted(
vevents_overridden,
key=lambda vevent: datetime.datetime.strptime(vevent.recurrence_id.value, dt_format)
key=lambda vevent: vevent.recurrence_id.value
)
)
else:

View file

@ -0,0 +1,28 @@
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTIMEZONE
LAST-MODIFIED:20040110T032845Z
TZID:US/Eastern
BEGIN:DAYLIGHT
DTSTART:20000404T020000
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
END:DAYLIGHT
BEGIN:STANDARD
DTSTART:20001026T020000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=US/Eastern:20060321T150000
DURATION:PT1H
RRULE:FREQ=WEEKLY;COUNT=5
SUMMARY:Recurring event
UID:event_weekly_rrule
END:VEVENT
END:VCALENDAR

View file

@ -70,6 +70,8 @@ permissions: RrWw""")
def _test_expand(self,
expected_uid: str,
start: str,
end: str,
expected_recurrence_ids: List[str],
expected_start_times: List[str],
expected_end_times: List[str],
@ -77,7 +79,7 @@ permissions: RrWw""")
nr_uids: int) -> None:
self.put("/calendar.ics/", get_file_content(f"{expected_uid}.ics"))
req_body_without_expand = \
"""<?xml version="1.0" encoding="utf-8" ?>
f"""<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
@ -86,7 +88,7 @@ permissions: RrWw""")
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20060103T000000Z" end="20060105T000000Z"/>
<C:time-range start="{start}" end="{end}"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
@ -117,17 +119,17 @@ permissions: RrWw""")
assert len(uids) == nr_uids
req_body_with_expand = \
"""<?xml version="1.0" encoding="utf-8" ?>
f"""<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<C:calendar-data>
<C:expand start="20060103T000000Z" end="20060105T000000Z"/>
<C:expand start="{start}" end="{end}"/>
</C:calendar-data>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20060103T000000Z" end="20060105T000000Z"/>
<C:time-range start="{start}" end="{end}"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
@ -170,6 +172,8 @@ permissions: RrWw""")
"""Test report with expand property"""
self._test_expand(
"event_daily_rrule",
"20060103T000000Z",
"20060105T000000Z",
["RECURRENCE-ID:20060103T170000Z", "RECURRENCE-ID:20060104T170000Z"],
["DTSTART:20060103T170000Z", "DTSTART:20060104T170000Z"],
[],
@ -181,6 +185,8 @@ permissions: RrWw""")
"""Test report with expand property for all day events"""
self._test_expand(
"event_full_day_rrule",
"20060103T000000Z",
"20060105T000000Z",
["RECURRENCE-ID:20060103", "RECURRENCE-ID:20060104", "RECURRENCE-ID:20060105"],
["DTSTART:20060103", "DTSTART:20060104", "DTSTART:20060105"],
["DTEND:20060104", "DTEND:20060105", "DTEND:20060106"],
@ -192,9 +198,33 @@ permissions: RrWw""")
"""Test report with expand property with overridden events"""
self._test_expand(
"event_daily_rrule_overridden",
"20060103T000000Z",
"20060105T000000Z",
["RECURRENCE-ID:20060103T170000Z", "RECURRENCE-ID:20060104T170000Z"],
["DTSTART:20060103T170000Z", "DTSTART:20060104T190000Z"],
[],
CONTAINS_TIMES,
2
)
def test_report_with_expand_property_timezone(self):
self._test_expand(
"event_weekly_rrule",
"20060320T000000Z",
"20060414T000000Z",
[
"RECURRENCE-ID:20060321T200000Z",
"RECURRENCE-ID:20060328T200000Z",
"RECURRENCE-ID:20060404T190000Z",
"RECURRENCE-ID:20060411T190000Z",
],
[
"DTSTART:20060321T200000Z",
"DTSTART:20060328T200000Z",
"DTSTART:20060404T190000Z",
"DTSTART:20060411T190000Z",
],
[],
CONTAINS_TIMES,
1
)