From 0e15021c11c6539776011f8f21b380ca18f76617 Mon Sep 17 00:00:00 2001 From: TowyTowy Date: Tue, 4 Aug 2026 20:05:04 +0200 Subject: [PATCH] Fix: time-range filter on VTODO with DTSTART/DUE and CREATED/COMPLETED visit_time_ranges() reuses a single "original_duration" variable for two unrelated purposes: the DTSTART->DUE span and the CREATED->COMPLETED span. When a VTODO carries all four properties (a completed task, which most clients write with CREATED and COMPLETED), the second assignment clobbers the first, and the DTSTART/DUE branch of the rfc4791-9.9 table then reconstructs DUE as DTSTART + (COMPLETED - CREATED). The elif chain already implements the RFC table correctly (DTSTART/DUE take precedence over CREATED/COMPLETED), so the CREATED/COMPLETED value is never wanted there. Keep it in its own variable. Effect: such a VTODO is filtered against a bogus interval, both in calendar-query REPORT and in item.find_time_range() (the enclosing range cached for the storage prefilter), so completed tasks go missing from - or wrongly appear in - client results. Co-Authored-By: Claude --- CHANGELOG.md | 1 + radicale/item/filter.py | 7 +++++-- radicale/tests/static/todo10.ics | 13 +++++++++++++ radicale/tests/test_base.py | 21 +++++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 radicale/tests/static/todo10.ics diff --git a/CHANGELOG.md b/CHANGELOG.md index 59d65adb..13101f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## 3.7.8.dev +* Fix: time-range filter on a VTODO having DTSTART/DUE and also CREATED/COMPLETED used the CREATED->COMPLETED duration instead of the DTSTART->DUE one, so completed tasks were missing from (or wrongly returned by) calendar-query REPORT results * Fix: sharing/proppatch: reject in case of write-access but 'p' is in permissions * Fix: sharing/by-map: catch collection path without trailing / (supporting "pimsync") * Add: [report] max_expand_occurrence option to separate from max_freebusy_occurrence diff --git a/radicale/item/filter.py b/radicale/item/filter.py index 74e571ca..789cafe3 100644 --- a/radicale/item/filter.py +++ b/radicale/item/filter.py @@ -442,7 +442,10 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str, completed = date_to_datetime(completed.value) if created is not None: created = date_to_datetime(created.value) - original_duration = (completed - created).total_seconds() + # NOTE: kept separate from "original_duration", otherwise a + # VTODO with DTSTART+DUE and also CREATED+COMPLETED would + # lose its DTSTART->DUE duration (see line 2 below) + completed_duration = (completed - created).total_seconds() elif created is not None: created = date_to_datetime(created.value) @@ -500,7 +503,7 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str, elif completed is not None and created is not None: # Line 5 completed = reference_date + timedelta( - seconds=original_duration) + seconds=completed_duration) if (range_fn(reference_date - SECOND, reference_date + SECOND, is_recurrence) or diff --git a/radicale/tests/static/todo10.ics b/radicale/tests/static/todo10.ics new file mode 100644 index 00000000..5acbde84 --- /dev/null +++ b/radicale/tests/static/todo10.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Radicale//NONSGML Radicale Server//EN +BEGIN:VTODO +UID:todo10 +SUMMARY:todo10 +DTSTART:20130901T180000Z +DUE:20130903T180000Z +CREATED:20130901T170000Z +COMPLETED:20130901T173000Z +STATUS:COMPLETED +END:VTODO +END:VCALENDAR diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index 506d0871..7c2c2902 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -2083,6 +2083,27 @@ permissions: RrWw""") """], "todo", items=range(1, 9)) assert "/calendar.ics/todo7.ics" in answer + def test_time_range_filter_todos_dtstart_due_completed(self) -> None: + """Report request with time-range filter on a completed todo which + also has DTSTART and DUE (rfc4791-9.9: DTSTART/DUE take + precedence over CREATED/COMPLETED).""" + # inside DTSTART..DUE, but outside CREATED..COMPLETED + answer = self._test_filter(["""\ + + + + +"""], "todo", items=(10,)) + assert "/calendar.ics/todo10.ics" in answer + # entirely before DTSTART and CREATED + answer = self._test_filter(["""\ + + + + +"""], "todo", items=(10,)) + assert "/calendar.ics/todo10.ics" not in answer + def test_time_range_filter_events_valarm(self) -> None: """Report request with time-range filter on events having absolute VALARM.""" answer = self._test_filter(["""\