From 248fc7e9e3a4df4b8c5a6f64388c19d08c47d0b2 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Mon, 30 May 2016 14:53:20 +0200 Subject: [PATCH] Implement time-range filters for events --- radicale/xmlutils.py | 78 ++++++++++++--- tests/static/{event.ics => event1.ics} | 6 +- tests/static/event2.ics | 31 ++++++ tests/static/event3.ics | 31 ++++++ tests/static/event4.ics | 30 ++++++ tests/static/event5.ics | 30 ++++++ tests/test_base.py | 129 +++++++++++++++++++------ 7 files changed, 294 insertions(+), 41 deletions(-) rename tests/static/{event.ics => event1.ics} (89%) create mode 100644 tests/static/event2.ics create mode 100644 tests/static/event3.ics create mode 100644 tests/static/event4.ics create mode 100644 tests/static/event5.ics diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py index ebcd43b..daaf8d7 100644 --- a/radicale/xmlutils.py +++ b/radicale/xmlutils.py @@ -29,6 +29,7 @@ import posixpath import re import xml.etree.ElementTree as ET from collections import OrderedDict +from datetime import datetime, timedelta, timezone from urllib.parse import unquote, urlparse import vobject @@ -146,9 +147,9 @@ def _comp_match(item, filter_, scope="collection"): return filter_.get("name") != tag if filter_[0].tag == _tag("C", "time-range"): # Point #3 of rfc4791-9.7.1 - if not _time_range_match(item, filter_): + if not _time_range_match(item.item, filter_[0], tag): return False - filter_.remove(filter_[0]) + filter_ = filter_[1:] # Point #4 of rfc4791-9.7.1 return all( _prop_match(item, child) if child.tag == _tag("C", "prop-filter") @@ -180,26 +181,81 @@ def _prop_match(item, filter_): return name not in vobject_item.contents if filter_[0].tag == _tag("C", "time-range"): # Point #3 of rfc4791-9.7.2 - if not _time_range_match(item, filter_[0]): + if not _time_range_match(vobject_item, filter_[0], name): return False - filter_.remove(filter_[0]) + filter_ = filter_[1:] elif filter_[0].tag == _tag("C", "text-match"): # Point #4 of rfc4791-9.7.2 if not _text_match(vobject_item, filter_[0], name): return False - filter_.remove(filter_[0]) + filter_ = filter_[1:] return all( _param_filter_match(vobject_item, param_filter, name) for param_filter in filter_) -def _time_range_match(item, filter_): +def _time_range_match(vobject_item, filter_, child_name): """Check whether the ``item`` matches the time-range ``filter_``. See rfc4791-9.9. """ - # TODO: implement this + start = filter_.get("start") + end = filter_.get("end") + if not start and not end: + return False + if start: + start = datetime.strptime(start, "%Y%m%dT%H%M%SZ") + else: + start = datetime.datetime.min + if end: + end = datetime.strptime(end, "%Y%m%dT%H%M%SZ") + else: + end = datetime.datetime.max + start = start.replace(tzinfo=timezone.utc) + end = end.replace(tzinfo=timezone.utc) + child = getattr(vobject_item, child_name.lower()) + + # Comments give the lines in the tables of the specification + if child_name == "VEVENT": + # TODO: check if there's a timezone + dtstart = child.dtstart.value + if not isinstance(dtstart, datetime): + dtstart_is_datetime = False + # TODO: changing dates to datetimes may be wrong because of tz + dtstart = datetime.combine(dtstart, datetime.min.time()).replace( + tzinfo=timezone.utc) + else: + dtstart_is_datetime = True + dtend = getattr(child, "dtend", None) + duration = getattr(child, "duration", None) + if dtend is not None: + # Line 1 + dtend = dtend.value + if not isinstance(dtend, datetime): + dtend = datetime.combine(dtend, datetime.min.time()).replace( + tzinfo=timezone.utc) + return start < dtend and end > dtstart + elif duration is not None: + duration = duration.value + if duration.seconds > 0: + # Line 2 + return start < dtstart + duration and end > dtstart + else: + # Line 3 + return start <= dtstart and end > dtstart + elif dtstart_is_datetime: + # Line 4 + return start <= dtstart and end > dtstart + else: + # Line 5 + return start < dtstart + timedelta(days=1) and end > dtstart + elif child_name == "VTODO": + # TODO: implement this + pass + elif child_name == "VJOURNAL": + # TODO: implement this + pass return True @@ -491,11 +547,11 @@ def _propfind_response(path, item, props, user, write=False): for href, _ in item.list(): event = item.get(href) if "vtimezone" in event.contents: - for timezone in event.vtimezone_list: - timezones.add(timezone) + for timezone_ in event.vtimezone_list: + timezones.add(timezone_) collection = vobject.iCalendar() - for timezone in timezones: - collection.add(timezone) + for timezone_ in timezones: + collection.add(timezone_) element.text = collection.serialize() elif tag == _tag("D", "displayname"): element.text = item.get_meta("D:displayname") or item.path diff --git a/tests/static/event.ics b/tests/static/event1.ics similarity index 89% rename from tests/static/event.ics rename to tests/static/event1.ics index 424d2c5..bc04d80 100644 --- a/tests/static/event.ics +++ b/tests/static/event1.ics @@ -23,12 +23,12 @@ BEGIN:VEVENT CREATED:20130902T150157Z LAST-MODIFIED:20130902T150158Z DTSTAMP:20130902T150158Z -UID:event +UID:event1 SUMMARY:Event ORGANIZER:mailto:unclesam@example.com ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com -DTSTART;TZID=Europe/Paris:20130902T180000 -DTEND;TZID=Europe/Paris:20130902T190000 +DTSTART;TZID=Europe/Paris:20130901T180000 +DTEND;TZID=Europe/Paris:20130901T190000 END:VEVENT END:VCALENDAR diff --git a/tests/static/event2.ics b/tests/static/event2.ics new file mode 100644 index 0000000..6f934fe --- /dev/null +++ b/tests/static/event2.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20130902T150157Z +LAST-MODIFIED:20130902T150158Z +DTSTAMP:20130902T150158Z +UID:event2 +SUMMARY:Event2 +DTSTART;TZID=Europe/Paris:20130902T180000 +DTEND;TZID=Europe/Paris:20130902T190000 +END:VEVENT +END:VCALENDAR diff --git a/tests/static/event3.ics b/tests/static/event3.ics new file mode 100644 index 0000000..18bbbe9 --- /dev/null +++ b/tests/static/event3.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20130902T150157Z +LAST-MODIFIED:20130902T150158Z +DTSTAMP:20130902T150158Z +UID:event3 +SUMMARY:Event3 +DTSTART;TZID=Europe/Paris:20130903 +DURATION:PT1H +END:VEVENT +END:VCALENDAR diff --git a/tests/static/event4.ics b/tests/static/event4.ics new file mode 100644 index 0000000..b4f3f82 --- /dev/null +++ b/tests/static/event4.ics @@ -0,0 +1,30 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20130902T150157Z +LAST-MODIFIED:20130902T150158Z +DTSTAMP:20130902T150158Z +UID:event4 +SUMMARY:Event4 +DTSTART;TZID=Europe/Paris:20130904T180000 +END:VEVENT +END:VCALENDAR diff --git a/tests/static/event5.ics b/tests/static/event5.ics new file mode 100644 index 0000000..e87af37 --- /dev/null +++ b/tests/static/event5.ics @@ -0,0 +1,30 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20130902T150157Z +LAST-MODIFIED:20130902T150158Z +DTSTAMP:20130902T150158Z +UID:event5 +SUMMARY:Event5 +DTSTART;TZID=Europe/Paris:20130905 +END:VEVENT +END:VCALENDAR diff --git a/tests/test_base.py b/tests/test_base.py index 7609083..ef4745d 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -54,8 +54,8 @@ class BaseRequests: """Add an event.""" self.request( "PUT", "/calendar.ics/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR") - event = get_file_content("event.ics") - path = "/calendar.ics/event.ics" + event = get_file_content("event1.ics") + path = "/calendar.ics/event1.ics" status, headers, answer = self.request("PUT", path, event) assert status == 201 status, headers, answer = self.request("GET", path) @@ -83,8 +83,8 @@ class BaseRequests: """Delete an event.""" self.request( "PUT", "/calendar.ics/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR") - event = get_file_content("event.ics") - path = "/calendar.ics/event.ics" + event = get_file_content("event1.ics") + path = "/calendar.ics/event1.ics" status, headers, answer = self.request("PUT", path, event) # Then we send a DELETE request status, headers, answer = self.request("DELETE", path) @@ -93,13 +93,14 @@ class BaseRequests: status, headers, answer = self.request("GET", "/calendar.ics/") assert "VEVENT" not in answer - def _test_filter(self, filters): + def _test_filter(self, filters, events=1): filters_text = "".join( "%s" % filter_ for filter_ in filters) self.request( "PUT", "/calendar.ics/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR") - event = get_file_content("event.ics") - self.request("PUT", "/calendar.ics/event.ics", event) + for i in range(events): + event = get_file_content("event%i.ics" % (i + 1)) + self.request("PUT", "/calendar.ics/event%i.ics" % (i + 1), event) status, headers, answer = self.request( "REPORT", "/calendar.ics", """ @@ -113,29 +114,29 @@ class BaseRequests: def test_calendar_tag_filter(self): """Report request with tag-based filter on calendar.""" - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics"""]) def test_item_tag_filter(self): """Report request with tag-based filter on an item.""" - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics """]) def test_item_not_tag_filter(self): """Report request with tag-based is-not filter on an item.""" - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -144,13 +145,13 @@ class BaseRequests: def test_item_prop_filter(self): """Report request with prop-based filter on an item.""" - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -159,7 +160,7 @@ class BaseRequests: def test_item_not_prop_filter(self): """Report request with prop-based is-not filter on an item.""" - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -167,7 +168,7 @@ class BaseRequests: """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -178,7 +179,7 @@ class BaseRequests: def test_mutiple_filters(self): """Report request with multiple filters on an item.""" - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -193,7 +194,7 @@ class BaseRequests: """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -206,7 +207,7 @@ class BaseRequests: """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -218,7 +219,7 @@ class BaseRequests: def test_text_match_filter(self): """Report request with text-match filter on calendar.""" - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -226,7 +227,7 @@ class BaseRequests: """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -234,7 +235,7 @@ class BaseRequests: """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -242,7 +243,7 @@ class BaseRequests: """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -253,7 +254,7 @@ class BaseRequests: def test_param_filter(self): """Report request with param-filter on calendar.""" - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -264,7 +265,7 @@ class BaseRequests: """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -275,7 +276,7 @@ class BaseRequests: """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -285,7 +286,7 @@ class BaseRequests: """]) - assert "href>/calendar.ics/event.ics/calendar.ics/event1.ics @@ -296,6 +297,80 @@ class BaseRequests: """]) + def test_time_range_filter(self): + """Report request with time-range filter on calendar.""" + answer = self._test_filter([""" + + + + + """], events=5) + assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics + + + + + + + + + """], events=5) + assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics + + + + """], events=5) + assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics + + + + """], events=5) + assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics + + + + """], events=5) + assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics + + + + """], events=5) + assert "href>/calendar.ics/event1.ics/calendar.ics/event2.ics/calendar.ics/event3.ics/calendar.ics/event4.ics/calendar.ics/event5.ics