diff --git a/radicale/xmlutils.py b/radicale/xmlutils.py
index bc87d14..864a5e8 100644
--- a/radicale/xmlutils.py
+++ b/radicale/xmlutils.py
@@ -118,6 +118,20 @@ def _href(collection, href):
href.lstrip("/"))
+def _date_to_datetime(date_):
+ """Transform a date to a UTC datetime.
+
+ If date_ is a datetime without timezone, return as UTC datetime. If date_
+ is already a datetime with timezone, return as is.
+
+ """
+ if not isinstance(date_, datetime):
+ date_ = datetime.combine(date_, datetime.min.time())
+ if not date_.tzinfo:
+ date_ = date_.replace(tzinfo=timezone.utc)
+ return date_
+
+
def _comp_match(item, filter_, scope="collection"):
"""Check whether the ``item`` matches the comp ``filter_``.
@@ -220,126 +234,156 @@ def _time_range_match(vobject_item, filter_, child_name):
if child_name == "VEVENT":
# TODO: check if there's a timezone
dtstart = child.dtstart.value
- if isinstance(dtstart, datetime) and not dtstart.tzinfo:
- dtstart = dtstart.replace(tzinfo=timezone.utc)
- 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)
+ if child.rruleset:
+ dtstarts = child.getrruleset(addRDate=True)
else:
- dtstart_is_datetime = True
+ dtstarts = (dtstart,)
+
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)
- if isinstance(dtend, datetime) and not dtend.tzinfo:
- dtend = dtend.replace(tzinfo=timezone.utc)
+ original_duration = (dtend - dtstart).total_seconds()
+ dtend = _date_to_datetime(dtend)
+
+ duration = getattr(child, "duration", None)
+ if duration is not None:
+ original_duration = duration = duration.value
+
+ for dtstart in dtstarts:
+ dtstart_is_datetime = isinstance(dtstart, datetime)
+ dtstart = _date_to_datetime(dtstart)
+
+ if dtstart > end:
+ break
+
+ if dtend is not None:
+ # Line 1
+ dtend = dtstart + timedelta(seconds=original_duration)
+ if start < dtend and end > dtstart:
+ return True
+ elif duration is not None:
+ if original_duration is None:
+ original_duration = duration.seconds
+ if duration.seconds > 0:
+ # Line 2
+ if start < dtstart + duration and end > dtstart:
+ return True
+ elif start <= dtstart and end > dtstart:
+ # Line 3
+ return True
+ elif dtstart_is_datetime:
+ # Line 4
+ if start <= dtstart and end > dtstart:
+ return True
+ elif start < dtstart + timedelta(days=1) and end > dtstart:
+ # Line 5
+ return True
- 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
dtstart = getattr(child, "dtstart", None)
duration = getattr(child, "duration", None)
due = getattr(child, "due", None)
completed = getattr(child, "completed", None)
created = getattr(child, "created", None)
- if dtstart is not None and duration is not None:
- # Line 1
- dtstart = dtstart.value
- if not isinstance(dtstart, datetime):
- dtstart = (datetime.combine(dtstart, datetime.min.time())
- .replace(tzinfo=timezone.utc))
+ if dtstart is not None:
+ dtstart = _date_to_datetime(dtstart.value)
+ if duration is not None:
duration = duration.value
- return (start <= dtstart + duration and
- (end > dtstart or end >= dtstart + duration))
- elif dtstart is not None and due is not None:
- # Line 2
- dtstart = dtstart.value
- if not isinstance(dtstart, datetime):
- dtstart = (datetime.combine(dtstart, datetime.min.time())
- .replace(tzinfo=timezone.utc))
- due = due.value
- if not isinstance(due, datetime):
- due = datetime.combine(due, datetime.min.time()).replace(
- tzinfo=timezone.utc)
- return ((start < due or start <= dtstart) and
- (end > dtstart or end >= due))
- elif dtstart is not None:
- # Line 3
- dtstart = dtstart.value
- if not isinstance(dtstart, datetime):
- dtstart = (datetime.combine(dtstart, datetime.min.time())
- .replace(tzinfo=timezone.utc))
- return start <= dtstart and end > dtstart
- elif due is not None:
- # Line 4
- due = due.value
- if not isinstance(due, datetime):
- due = datetime.combine(due, datetime.min.time()).replace(
- tzinfo=timezone.utc)
- return start < due and end >= due
- elif completed is not None and created is not None:
- # Line 5
- completed = completed.value
- created = created.value
- return ((start <= created or start <= completed) and
- (end >= created or end >= completed))
- elif completed is not None:
- # Line 6
- completed = completed.value
- return start <= completed and end >= completed
+ if due is not None:
+ due = _date_to_datetime(due.value)
+ if dtstart is not None:
+ original_duration = (due - dtstart).total_seconds()
+ if completed is not None:
+ completed = _date_to_datetime(completed.value)
+ if created is not None:
+ created = _date_to_datetime(created.value)
+ original_duration = (completed - created).total_seconds()
elif created is not None:
- # Line 7
- created = created.value
- return end > created
+ created = _date_to_datetime(created.value)
+
+ if child.rruleset:
+ reference_dates = child.getrruleset(addRDate=True)
else:
- return True
+ if dtstart is not None:
+ reference_dates = (dtstart,)
+ elif due is not None:
+ reference_dates = (due,)
+ elif completed is not None:
+ reference_dates = (completed,)
+ elif created is not None:
+ reference_dates = (created,)
+ else:
+ # Line 8
+ return True
+
+ for reference_date in reference_dates:
+ reference_date = _date_to_datetime(reference_date)
+ if reference_date > end:
+ break
+
+ if dtstart is not None and duration is not None:
+ # Line 1
+ if start <= reference_date + duration and (
+ end > reference_date or
+ end >= reference_date + duration):
+ return True
+ elif dtstart is not None and due is not None:
+ # Line 2
+ due = reference_date + timedelta(seconds=original_duration)
+ if (start < due or start <= reference_date) and (
+ end > reference_date or end >= due):
+ return True
+ elif dtstart is not None:
+ if start <= reference_date and end > reference_date:
+ return True
+ elif due is not None:
+ # Line 4
+ if start < reference_date and end >= reference_date:
+ return True
+ elif completed is not None and created is not None:
+ # Line 5
+ completed = reference_date + timedelta(
+ seconds=original_duration)
+ if (start <= reference_date or start <= completed) and (
+ end >= reference_date or end >= completed):
+ return True
+ elif completed is not None:
+ # Line 6
+ if start <= reference_date and end >= reference_date:
+ return True
+ elif created is not None:
+ # Line 7
+ if end > reference_date:
+ return True
elif child_name == "VJOURNAL":
dtstart = getattr(child, "dtstart", None)
if dtstart is not None:
dtstart = 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))
+ if child.rruleset:
+ dtstarts = child.getrruleset(addRDate=True)
else:
- dtstart_is_datetime = True
+ dtstarts = (dtstart,)
- if dtstart_is_datetime:
- # Line 1
- return start <= dtstart and end > dtstart
- else:
- # Line 2
- return start < dtstart + timedelta(days=1) and end > dtstart
- else:
- # Line 3
- return False
+ for dtstart in dtstarts:
+ dtstart_is_datetime = isinstance(dtstart, datetime)
+ dtstart = _date_to_datetime(dtstart)
- return True
+ if dtstart > end:
+ break
+
+ if dtstart_is_datetime:
+ # Line 1
+ if start <= dtstart and end > dtstart:
+ return True
+ elif start < dtstart + timedelta(days=1) and end > dtstart:
+ # Line 2
+ return True
+
+ return False
def _text_match(vobject_item, filter_, child_name, attrib_name=None):
diff --git a/tests/static/event2.ics b/tests/static/event2.ics
index 6f934fe..2844312 100644
--- a/tests/static/event2.ics
+++ b/tests/static/event2.ics
@@ -27,5 +27,6 @@ UID:event2
SUMMARY:Event2
DTSTART;TZID=Europe/Paris:20130902T180000
DTEND;TZID=Europe/Paris:20130902T190000
+RRULE:FREQ=WEEKLY
END:VEVENT
END:VCALENDAR
diff --git a/tests/static/journal2.ics b/tests/static/journal2.ics
index 336499b..27cb7cc 100644
--- a/tests/static/journal2.ics
+++ b/tests/static/journal2.ics
@@ -25,7 +25,8 @@ UID:journal2
DTSTAMP:19950817T000000
DTSTART;TZID=Europe/Paris:20000101T100000
SUMMARY:happy new year
-DESCRIPTION: Happy new year 2001 !
+DESCRIPTION: Happy new year !
+RRULE:FREQ=YEARLY
END:VJOURNAL
END:VCALENDAR
diff --git a/tests/static/todo.ics b/tests/static/todo.ics
deleted file mode 100644
index 457f763..0000000
--- a/tests/static/todo.ics
+++ /dev/null
@@ -1,11 +0,0 @@
-BEGIN:VCALENDAR
-PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
-VERSION:2.0
-BEGIN:VTODO
-CREATED:20130903T091105Z
-LAST-MODIFIED:20130903T091108Z
-DTSTAMP:20130903T091108Z
-UID:todo
-SUMMARY:Todo
-END:VTODO
-END:VCALENDAR
diff --git a/tests/static/todo1.ics b/tests/static/todo1.ics
index aa323cb..0ffdede 100644
--- a/tests/static/todo1.ics
+++ b/tests/static/todo1.ics
@@ -22,5 +22,7 @@ END:VTIMEZONE
BEGIN:VTODO
DTSTART;TZID=Europe/Paris:20130901T220000
DURATION:PT1H
+SUMMARY:Todo
+UID:todo
END:VTODO
END:VCALENDAR
diff --git a/tests/static/todo2.ics b/tests/static/todo2.ics
index e4b3bad..0642ef6 100644
--- a/tests/static/todo2.ics
+++ b/tests/static/todo2.ics
@@ -22,5 +22,6 @@ END:VTIMEZONE
BEGIN:VTODO
DTSTART;TZID=Europe/Paris:20130901T180000
DUE;TZID=Europe/Paris:20130903T180000
+RRULE:FREQ=MONTHLY
END:VTODO
END:VCALENDAR
diff --git a/tests/test_base.py b/tests/test_base.py
index ffbc5b4..b55f582 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -69,8 +69,8 @@ class BaseRequests:
"""Add a todo."""
self.request(
"PUT", "/calendar.ics/", "BEGIN:VCALENDAR\r\nEND:VCALENDAR")
- todo = get_file_content("todo.ics")
- path = "/calendar.ics/todo.ics"
+ todo = get_file_content("todo1.ics")
+ path = "/calendar.ics/todo1.ics"
status, headers, answer = self.request("PUT", path, todo)
assert status == 201
status, headers, answer = self.request("GET", path)
@@ -341,7 +341,7 @@ class BaseRequests:
answer = self._test_filter(["""
-
+
"""], items=5)
assert "href>/calendar.ics/event1.ics" not in answer
@@ -372,6 +372,41 @@ class BaseRequests:
assert "href>/calendar.ics/event4.ics" not in answer
assert "href>/calendar.ics/event5.ics" not in answer
+ def test_time_range_filter_events_rrule(self):
+ """Report request with time-range filter on events with rrules."""
+ answer = self._test_filter(["""
+
+
+
+
+ """], "event", items=2)
+ assert "href>/calendar.ics/event1.ics" in answer
+ assert "href>/calendar.ics/event2.ics" in answer
+ answer = self._test_filter(["""
+
+
+
+
+ """], "event", items=2)
+ assert "href>/calendar.ics/event1.ics" not in answer
+ assert "href>/calendar.ics/event2.ics" in answer
+ answer = self._test_filter(["""
+
+
+
+
+ """], "event", items=2)
+ assert "href>/calendar.ics/event1.ics" not in answer
+ assert "href>/calendar.ics/event2.ics" not in answer
+ answer = self._test_filter(["""
+
+
+
+
+ """], "event", items=2)
+ assert "href>/calendar.ics/event1.ics" not in answer
+ assert "href>/calendar.ics/event2.ics" not in answer
+
def test_time_range_filter_todos(self):
"""Report request with time-range filter on todos."""
answer = self._test_filter(["""
@@ -431,6 +466,41 @@ class BaseRequests:
"""], "todo", items=8)
assert "href>/calendar.ics/todo7.ics" in answer
+ def test_time_range_filter_todos_rrule(self):
+ """Report request with time-range filter on todos with rrules."""
+ answer = self._test_filter(["""
+
+
+
+
+ """], "todo", items=2)
+ assert "href>/calendar.ics/todo1.ics" in answer
+ assert "href>/calendar.ics/todo2.ics" in answer
+ answer = self._test_filter(["""
+
+
+
+
+ """], "todo", items=2)
+ assert "href>/calendar.ics/todo1.ics" not in answer
+ assert "href>/calendar.ics/todo2.ics" in answer
+ answer = self._test_filter(["""
+
+
+
+
+ """], "todo", items=2)
+ assert "href>/calendar.ics/todo1.ics" not in answer
+ assert "href>/calendar.ics/todo2.ics" in answer
+ answer = self._test_filter(["""
+
+
+
+
+ """], "todo", items=2)
+ assert "href>/calendar.ics/todo1.ics" not in answer
+ assert "href>/calendar.ics/todo2.ics" not in answer
+
def test_time_range_filter_journals(self):
"""Report request with time-range filter on journals."""
answer = self._test_filter(["""
@@ -467,7 +537,7 @@ class BaseRequests:
"""], "journal", items=3)
assert "href>/calendar.ics/journal1.ics" not in answer
- assert "href>/calendar.ics/journal2.ics" not in answer
+ assert "href>/calendar.ics/journal2.ics" in answer
assert "href>/calendar.ics/journal3.ics" not in answer
answer = self._test_filter(["""
@@ -479,6 +549,33 @@ class BaseRequests:
assert "href>/calendar.ics/journal2.ics" in answer
assert "href>/calendar.ics/journal3.ics" in answer
+ def test_time_range_filter_journals_rrule(self):
+ """Report request with time-range filter on journals with rrules."""
+ answer = self._test_filter(["""
+
+
+
+
+ """], "journal", items=2)
+ assert "href>/calendar.ics/journal1.ics" not in answer
+ assert "href>/calendar.ics/journal2.ics" in answer
+ answer = self._test_filter(["""
+
+
+
+
+ """], "journal", items=2)
+ assert "href>/calendar.ics/journal1.ics" not in answer
+ assert "href>/calendar.ics/journal2.ics" in answer
+ answer = self._test_filter(["""
+
+
+
+
+ """], "journal", items=2)
+ assert "href>/calendar.ics/journal1.ics" not in answer
+ assert "href>/calendar.ics/journal2.ics" not in answer
+
class TestMultiFileSystem(BaseRequests, BaseTest):
"""Base class for filesystem tests."""