diff --git a/radicale/app/report.py b/radicale/app/report.py index ce393211..93957b60 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -29,6 +29,7 @@ from typing import (Callable, Iterable, Iterator, List, Optional, Sequence, from urllib.parse import unquote, urlparse import vobject.base +from dateutil import rrule from vobject.base import ContentLine import radicale.item as radicale_item @@ -196,14 +197,10 @@ def _expand( start: datetime.datetime, end: datetime.datetime, ) -> ET.Element: - rruleset = None - if hasattr(item.vobject_item.vevent, 'rrule'): - rruleset = item.vobject_item.vevent.getrruleset() - - expanded_item = _make_vobject_expanded_item(item) + expanded_item, rruleset = _make_vobject_expanded_item(item) if rruleset: - recurrences = rruleset.between(start, end) + recurrences = rruleset.between(start, end, inc=True) expanded: vobject.base.Component = copy.copy(expanded_item.vobject_item) is_expanded_filled: bool = False @@ -232,7 +229,7 @@ def _expand( def _make_vobject_expanded_item( item: radicale_item.Item -) -> radicale_item.Item: +) -> Tuple[radicale_item.Item, Optional[rrule.rruleset]]: # 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 @@ -243,17 +240,33 @@ def _make_vobject_expanded_item( item = copy.copy(item) vevent = item.vobject_item.vevent - start_utc = vevent.dtstart.value.astimezone(datetime.timezone.utc) - vevent.dtstart = ContentLine( - name='DTSTART', - value=start_utc.strftime('%Y%m%dT%H%M%SZ'), params={}) + 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) + + vevent.dtstart = ContentLine(name='DTSTART', value=start_utc, params=[]) dt_end = getattr(vevent, 'dtend', None) if dt_end is not None: - end_utc = dt_end.value.astimezone(datetime.timezone.utc) - vevent.dtend = ContentLine( - name='DTEND', - value=end_utc.strftime('%Y%m%dT%H%M%SZ'), params={}) + if type(vevent.dtend.value) is datetime.date: + end_utc = datetime.datetime.combine( + dt_end.value, time=datetime.time(0, 0, 0)).replace(tzinfo=datetime.timezone.utc) + else: + end_utc = dt_end.value.astimezone(datetime.timezone.utc) + + vevent.dtend = ContentLine(name='DTEND', value=end_utc, params={}) + + rruleset = None + if hasattr(item.vobject_item.vevent, 'rrule'): + rruleset = vevent.getrruleset() + + # There is something strage behavour during serialization native datetime, so converting manualy + vevent.dtstart.value = vevent.dtstart.value.strftime('%Y%m%dT%H%M%SZ') + if dt_end is not None: + vevent.dtend.value = vevent.dtend.value.strftime('%Y%m%dT%H%M%SZ') timezones_to_remove = [] for component in item.vobject_item.components(): @@ -271,7 +284,7 @@ def _make_vobject_expanded_item( except AttributeError: pass - return item + return item, rruleset def xml_item_response(base_prefix: str, href: str, diff --git a/radicale/tests/static/event_full_day_rrule.ics b/radicale/tests/static/event_full_day_rrule.ics new file mode 100644 index 00000000..88f81c7d --- /dev/null +++ b/radicale/tests/static/event_full_day_rrule.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTIMEZONE +LAST-MODIFIED:20040110T032845Z +TZID:US/Eastern +BEGIN:DAYLIGHT +DTSTART:20000404 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20001026 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=US/Eastern:20060102 +DTEND;TZID=US/Eastern:20060103 +RRULE:FREQ=DAILY;COUNT=5 +SUMMARY:Recurring event +UID:event_full_day_rrule +DTSTAMP:20060102T094829Z +END:VEVENT +END:VCALENDAR + diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index d79caaad..b837f52e 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -1604,11 +1604,105 @@ permissions: RrWw""") uids.append(line) if line.startswith("RECURRENCE-ID:"): + assert line in ["RECURRENCE-ID:20060103T170000Z", "RECURRENCE-ID:20060104T170000Z"] recurrence_ids.append(line) + if line.startswith("DTSTART:"): + assert line == "DTSTART:20060102T170000Z" + assert len(uids) == 2 assert len(set(recurrence_ids)) == 2 + def test_report_with_expand_property_all_day_event(self) -> None: + """Test report with expand property""" + self.put("/calendar.ics/", get_file_content("event_full_day_rrule.ics")) + req_body_without_expand = \ + """ + + + + + + + + + + + + + + """ + _, responses = self.report("/calendar.ics/", req_body_without_expand) + assert len(responses) == 1 + + response_without_expand = responses['/calendar.ics/event_full_day_rrule.ics'] + assert not isinstance(response_without_expand, int) + status, element = response_without_expand["C:calendar-data"] + + assert status == 200 and element.text + + assert "RRULE" in element.text + assert "RECURRENCE-ID" not in element.text + + uids: List[str] = [] + for line in element.text.split("\n"): + if line.startswith("UID:"): + uid = line[len("UID:"):] + assert uid == "event_full_day_rrule" + uids.append(uid) + + assert len(uids) == 1 + + req_body_with_expand = \ + """ + + + + + + + + + + + + + + + """ + + _, responses = self.report("/calendar.ics/", req_body_with_expand) + + assert len(responses) == 1 + + response_with_expand = responses['/calendar.ics/event_full_day_rrule.ics'] + assert not isinstance(response_with_expand, int) + status, element = response_with_expand["C:calendar-data"] + + assert status == 200 and element.text + assert "RRULE" not in element.text + assert "BEGIN:VTIMEZONE" not in element.text + + uids = [] + recurrence_ids = [] + for line in element.text.split("\n"): + if line.startswith("UID:"): + assert line == "UID:event_full_day_rrule" + uids.append(line) + + if line.startswith("RECURRENCE-ID:"): + assert line in ["RECURRENCE-ID:20060103T000000Z", "RECURRENCE-ID:20060104T000000Z", "RECURRENCE-ID:20060105T000000Z"] + recurrence_ids.append(line) + + if line.startswith("DTSTART:"): + assert line == "DTSTART:20060102T000000Z" + + if line.startswith("DTEND:"): + assert line == "DTEND:20060103T000000Z" + + assert len(uids) == 3 + assert len(set(recurrence_ids)) == 3 + def test_propfind_sync_token(self) -> None: """Retrieve the sync-token with a propfind request""" calendar_path = "/calendar.ics/"