diff --git a/radicale/app/report.py b/radicale/app/report.py index 43d89916..da06b61d 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -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: diff --git a/radicale/tests/static/event_weekly_rrule.ics b/radicale/tests/static/event_weekly_rrule.ics new file mode 100644 index 00000000..f5e982cd --- /dev/null +++ b/radicale/tests/static/event_weekly_rrule.ics @@ -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 diff --git a/radicale/tests/test_expand.py b/radicale/tests/test_expand.py index 708c99fd..1070bc77 100644 --- a/radicale/tests/test_expand.py +++ b/radicale/tests/test_expand.py @@ -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 = \ - """ + f""" @@ -86,7 +88,7 @@ permissions: RrWw""") - + @@ -117,17 +119,17 @@ permissions: RrWw""") assert len(uids) == nr_uids req_body_with_expand = \ - """ + f""" - + - + @@ -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 + )