Skip to content

Commit f38fee2

Browse files
authored
Fix for recurring tasks with recurrence-instances with mixed STATUS
Fixes #495 This fixes two things: * Non-expanded recurring tasks with mixed STATUS from the server would be silently ignored if asking for pending tasks. Clearly a bug. This bug happened before client-side expansion. * Client-side expansion should consider that we may have asked only for pending tasks - then the COMPLETED recurrences should be ignored.
1 parent b01a10e commit f38fee2

4 files changed

Lines changed: 157 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Python 3.7 is no longer tested (dependency problems) - but it should work. Plea
2828
* Bugfix for saving component failing on multi-component recurrence objects - https://github.com/python-caldav/caldav/pull/467
2929
* Some exotic servers may return object URLs on search, but it does not work out to fetch the calendar data. Now it will log an error instead of raising an error in such cases.
3030
* Some workarounds and fixes for getting tests passing on all the test servers I had at hand in https://github.com/python-caldav/caldav/pull/492
31+
* Search for todo-items would ignore recurring tasks with COMPLETED recurrence instances, ref https://github.com/python-caldav/caldav/issues/495, fixed in https://github.com/python-caldav/caldav/pull/496
3132

3233
### Changed
3334

caldav/calendarobjectresource.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ def split_expanded(self) -> List[Self]:
160160
ret.append(obj)
161161
return ret
162162

163-
def expand_rrule(self, start: datetime, end: datetime) -> None:
163+
def expand_rrule(
164+
self, start: datetime, end: datetime, include_completed: bool = True
165+
) -> None:
164166
"""This method will transform the calendar content of the
165167
event and expand the calendar data from a "master copy" with
166168
RRULE set and into a "recurrence set" with RECURRENCE-ID set
@@ -186,6 +188,7 @@ def expand_rrule(self, start: datetime, end: datetime) -> None:
186188
# FIXME too much copying
187189
stripped_event = self.copy(keep_uid=True)
188190

191+
## TODO: use icalendar_instance instead
189192
if stripped_event.vobject_instance is None:
190193
raise ValueError(
191194
"Unexpected value None for stripped_event.vobject_instance"
@@ -203,6 +206,13 @@ def expand_rrule(self, start: datetime, end: datetime) -> None:
203206
calendar = self.icalendar_instance
204207
calendar.subcomponents = []
205208
for occurrence in recurrings:
209+
## Ignore completed task recurrences
210+
if (
211+
not include_completed
212+
and occurrence.name == "VTODO"
213+
and occurrence.get("STATUS") in ("COMPLETED", "CANCELLED")
214+
):
215+
continue
206216
if "RECURRENCE-ID" not in occurrence:
207217
occurrence.add("RECURRENCE-ID", occurrence.get("DTSTART"))
208218
calendar.add_component(occurrence)
@@ -1329,8 +1339,6 @@ def complete(
13291339
self.save()
13301340

13311341
def _complete_ical(self, i=None, completion_timestamp=None) -> None:
1332-
## my idea was to let self.complete call this one ... but self.complete
1333-
## should use vobject and not icalendar library due to backward compatibility.
13341342
if i is None:
13351343
i = self.icalendar_component
13361344
assert self._is_pending(i)

caldav/collection.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -850,10 +850,9 @@ def search(
850850
## and still, Zimbra seems to deliver too many TODOs in the
851851
## matches2 ... let's do some post-filtering in case the
852852
## server fails in filtering things the right way
853-
if "STATUS:NEEDS-ACTION" in item.data or (
854-
"\nCOMPLETED:" not in item.data
855-
and "\nSTATUS:COMPLETED" not in item.data
856-
and "\nSTATUS:CANCELLED" not in item.data
853+
if any(
854+
x.get("STATUS") not in ("COMPLETED", "CANCELLED")
855+
for x in item.icalendar_instance.subcomponents
857856
):
858857
objects.append(item)
859858
else:
@@ -925,7 +924,7 @@ def search(
925924
continue
926925
recurrence_properties = ["exdate", "exrule", "rdate", "rrule"]
927926
if any(key in component for key in recurrence_properties):
928-
o.expand_rrule(start, end)
927+
o.expand_rrule(start, end, include_completed=include_completed)
929928

930929
## An expanded recurring object comes as one Event() with
931930
## icalendar data containing multiple objects. The caller may

tests/test_caldav_unit.py

Lines changed: 141 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,115 @@
154154
END:VTODO
155155
END:VCALENDAR"""
156156

157+
## from https://github.com/python-caldav/caldav/issues/495
158+
recurring_task_response = """<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
159+
<d:response>
160+
<d:href>/remote.php/dav/calendars/oxi/personal/A9FFE819-5DDB-4947-A09C-308EEE5DA1F9.ics</d:href>
161+
<d:propstat>
162+
<d:prop>
163+
<cal:calendar-data>BEGIN:VCALENDAR
164+
VERSION:2.0
165+
PRODID:+//IDN bitfire.at//ical4android (at.techbee.jtx)
166+
BEGIN:VTODO
167+
DTSTAMP:20250522T075151Z
168+
UID:8a8736b4-bc35-4085-a98b-89c2f52a5c51
169+
SEQUENCE:23
170+
CREATED:20250411T233004Z
171+
LAST-MODIFIED:20250522T075137Z
172+
SUMMARY:Clean Rosie filter
173+
DTSTART;VALUE=DATE:20250415
174+
RRULE:FREQ=WEEKLY;BYDAY=TU,FR
175+
PRIORITY:0
176+
END:VTODO
177+
BEGIN:VTODO
178+
DTSTAMP:20250522T075151Z
179+
UID:8a8736b4-bc35-4085-a98b-89c2f52a5c51
180+
SEQUENCE:1
181+
CREATED:20250411T234336Z
182+
LAST-MODIFIED:20250414T082134Z
183+
SUMMARY:Clean Rosie filter
184+
STATUS:COMPLETED
185+
DTSTART;VALUE=DATE:20250415
186+
RECURRENCE-ID;VALUE=DATE:20250415
187+
COMPLETED:20250414T082134Z
188+
PERCENT-COMPLETE:100
189+
PRIORITY:0
190+
END:VTODO
191+
BEGIN:VTODO
192+
DTSTAMP:20250522T075151Z
193+
UID:8a8736b4-bc35-4085-a98b-89c2f52a5c51
194+
SEQUENCE:1
195+
CREATED:20250411T234336Z
196+
LAST-MODIFIED:20250414T082134Z
197+
SUMMARY:Clean Rosie filter
198+
STATUS:CANCELLED
199+
DTSTART;VALUE=DATE:20250418
200+
RECURRENCE-ID;VALUE=DATE:20250418
201+
PRIORITY:0
202+
END:VTODO
203+
BEGIN:VTODO
204+
DTSTAMP:20250522T075151Z
205+
UID:8a8736b4-bc35-4085-a98b-89c2f52a5c51
206+
SEQUENCE:1
207+
CREATED:20250411T234336Z
208+
LAST-MODIFIED:20250414T082134Z
209+
SUMMARY:Clean Rosie filter
210+
STATUS:CANCELLED
211+
DTSTART;VALUE=DATE:20250422
212+
RECURRENCE-ID;VALUE=DATE:20250422
213+
PRIORITY:0
214+
END:VTODO
215+
BEGIN:VTODO
216+
DTSTAMP:20250522T075151Z
217+
UID:8a8736b4-bc35-4085-a98b-89c2f52a5c51
218+
SEQUENCE:1
219+
CREATED:20250411T234336Z
220+
LAST-MODIFIED:20250425T124511Z
221+
SUMMARY:Clean Rosie filter
222+
STATUS:COMPLETED
223+
DTSTART;VALUE=DATE:20250425
224+
RECURRENCE-ID;VALUE=DATE:20250425
225+
COMPLETED:20250425T124511Z
226+
PERCENT-COMPLETE:100
227+
PRIORITY:0
228+
END:VTODO
229+
BEGIN:VTODO
230+
DTSTAMP:20250522T075151Z
231+
UID:8a8736b4-bc35-4085-a98b-89c2f52a5c51
232+
SEQUENCE:1
233+
CREATED:20250411T234336Z
234+
LAST-MODIFIED:20250425T124511Z
235+
SUMMARY:Clean Rosie filter
236+
STATUS:CANCELLED
237+
DTSTART;VALUE=DATE:20250429
238+
RECURRENCE-ID;VALUE=DATE:20250429
239+
COMPLETED:20250425T124511Z
240+
PERCENT-COMPLETE:100
241+
PRIORITY:0
242+
END:VTODO
243+
BEGIN:VTODO
244+
DTSTAMP:20250522T075151Z
245+
UID:8a8736b4-bc35-4085-a98b-89c2f52a5c51
246+
SEQUENCE:1
247+
CREATED:20250411T234336Z
248+
LAST-MODIFIED:20250502T113705Z
249+
SUMMARY:Clean Rosie filter
250+
STATUS:COMPLETED
251+
DTSTART;VALUE=DATE:20250502
252+
RECURRENCE-ID;VALUE=DATE:20250502
253+
COMPLETED:20250502T113705Z
254+
PERCENT-COMPLETE:100
255+
PRIORITY:0
256+
END:VTODO
257+
END:VCALENDAR
258+
</cal:calendar-data>
259+
</d:prop>
260+
<d:status>HTTP/1.1 200 OK</d:status>
261+
</d:propstat>
262+
</d:response>
263+
</d:multistatus>
264+
"""
265+
157266

158267
def MockedDAVResponse(text, davclient=None):
159268
"""
@@ -167,14 +276,20 @@ def MockedDAVResponse(text, davclient=None):
167276
return DAVResponse(resp, davclient)
168277

169278

170-
def MockedDAVClient(xml_returned):
279+
class MockedDAVClient(DAVClient):
171280
"""
172281
For unit testing - a mocked DAVClient returning some specific content every time
173282
a request is performed
174283
"""
175-
client = DAVClient(url="https://somwhere.in.the.universe.example/some/caldav/root")
176-
client.request = mock.MagicMock(return_value=MockedDAVResponse(xml_returned))
177-
return client
284+
285+
def __init__(self, xml_returned):
286+
self.xml_returned = xml_returned
287+
DAVClient.__init__(
288+
self, url="https://somwhere.in.the.universe.example/some/caldav/root"
289+
)
290+
291+
def request(self, *largs, **kwargs):
292+
return MockedDAVResponse(self.xml_returned)
178293

179294

180295
class TestExpandRRule:
@@ -272,6 +387,28 @@ def testRequestNonAscii(self, mocked):
272387
assert response.status == 200
273388
assert response.tree is None
274389

390+
def testSearchForRecurringTask(self):
391+
client = MockedDAVClient(recurring_task_response)
392+
calendar = Calendar(client, url="/calendar/issue491/")
393+
mytasks = calendar.search(todo=True, expand=False)
394+
assert len(mytasks) == 1
395+
mytasks = calendar.search(
396+
todo=True,
397+
expand="client",
398+
start=datetime(2025, 5, 5),
399+
end=datetime(2025, 6, 5),
400+
)
401+
assert len(mytasks) == 9
402+
403+
## It should not include the COMPLETED recurrences
404+
mytasks = calendar.search(
405+
todo=True,
406+
expand="client",
407+
start=datetime(2025, 1, 1),
408+
end=datetime(2025, 6, 5),
409+
)
410+
assert len(mytasks) == 9
411+
275412
def testLoadByMultiGet404(self):
276413
xml = """
277414
<D:multistatus xmlns:D="DAV:">

0 commit comments

Comments
 (0)