diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index 71aefa2d..1f55b485 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -2652,6 +2652,10 @@ def _next(self, ts=None, i=None, dtstart=None, rrule=None, by=None, no_count=Tru dtstart = dtstart.astimezone(timezone.utc) if not ts: ts = dtstart + elif isinstance(ts, datetime) and isinstance(dtstart, datetime) and ts < dtstart: + ## Completed before DTSTART (case 1A): the occurrence at + ## DTSTART is the one done, so look past it (issue #782) + ts = dtstart ## Counting is taken care of other places if no_count and "COUNT" in rrule: rrule = rrule.copy() diff --git a/changes/782.fixed.md b/changes/782.fixed.md new file mode 100644 index 00000000..ad2be0f2 --- /dev/null +++ b/changes/782.fixed.md @@ -0,0 +1 @@ +`complete(handle_rrule=True)` on a recurring task with fixed times (`BY*` parts in the `RRULE`, e.g. `FREQ=YEARLY;BYMONTH=4,9`) did not advance the task when it was completed before its `DTSTART`: the next occurrence was looked for after the completion time, which found the occurrence just completed. It is now looked for after `DTSTART`. diff --git a/docs/design/TODO.md b/docs/design/TODO.md index 7eba3721..34bb8358 100644 --- a/docs/design/TODO.md +++ b/docs/design/TODO.md @@ -4,6 +4,10 @@ `search()` decides the `!=` / `<>`, `!contains` and `def` operators client-side and leaves them out of the server query (https://github.com/python-caldav/caldav/issues/568). A caldav-server-tester probe for `negate-condition` support, and the compatibility_hints flag it would feed, would let `search()` send `!contains` as a negated `text-match` to the servers that honour it. +## Completing an all-day fixed-time recurring task + +`Todo._next()` in `caldav/calendarobjectresource.py` raises `TypeError` (naive vs aware) for a task with `DTSTART;VALUE=DATE` and `BY*` parts in its `RRULE`: dateutil turns the date into a naive datetime and compares it with the UTC completion timestamp, so `complete(handle_rrule=True)` fails for such tasks. Fix: convert a date DTSTART to an aware datetime (or the timestamp to a date) before both the comparison and `rrulestr()`, with a unit test. Found in the review of the fix for https://github.com/python-caldav/caldav/issues/782. + ## Calendar cleanup in integration tests For servers where calendars cannot be created, the test will take the first available calendar it finds and wipe it. That is not acceptable without the user explicitly configuring that this is OK. diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index e5a15fd9..dd2d257b 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -5972,6 +5972,49 @@ def test_build_safe_completed_count_one_skips_next(self) -> None: ) assert todo.icalendar_component["RRULE"]["COUNT"] == [1] + ## Issue https://github.com/python-caldav/caldav/issues/782: a + ## fixed-time task (BY* in the RRULE) done before its DTSTART + EARLY = todo6.replace("RRULE:FREQ=YEARLY", "RRULE:FREQ=YEARLY;BYMONTH=4,9") + + def test_next_fixed_time_completed_early(self) -> None: + todo = self._make_todo(self.EARLY) + ## done two days before the 1992-04-15 13:30 occurrence + early = datetime(1992, 4, 13, 10, tzinfo=timezone.utc) + assert todo._next(early) == datetime(1992, 9, 15, 13, 30, tzinfo=timezone.utc) + ## done after DTSTART: unchanged + late = datetime(1992, 4, 16, 10, tzinfo=timezone.utc) + assert todo._next(late) == datetime(1992, 9, 15, 13, 30, tzinfo=timezone.utc) + + def test_next_floating_fixed_time_completed_early(self) -> None: + """A floating DTSTART is taken as local time before the comparison.""" + todo = self._make_todo( + self.EARLY.replace("DTSTART:19920415T133000Z", "DTSTART:19920415T133000").replace( + "DUE:19920516T045959Z", "DUE:19920516T045959" + ) + ) + dtstart = datetime(1992, 4, 15, 13, 30).astimezone(timezone.utc) + assert todo._next(dtstart - timedelta(days=2)) == datetime(1992, 9, 15, 13, 30).astimezone( + timezone.utc + ) + + def test_safe_completion_completed_early_advances(self) -> None: + todo = self._make_todo(self.EARLY) + completed = todo._build_recurring_safe_completed( + datetime(1992, 4, 13, 10, tzinfo=timezone.utc) + ) + assert completed is not None + assert todo.icalendar_component["DTSTART"].dt == datetime( + 1992, 9, 15, 13, 30, tzinfo=timezone.utc + ) + + def test_thisandfuture_completed_early_advances(self) -> None: + todo = self._make_todo(self.EARLY) + todo._prepare_recurring_thisandfuture(datetime(1992, 4, 13, 10, tzinfo=timezone.utc)) + thisandfuture = todo.icalendar_instance.subcomponents[-1] + nxt = datetime(1992, 9, 15, 13, 30, tzinfo=timezone.utc) + assert thisandfuture["RECURRENCE-ID"].dt == nxt + assert thisandfuture["DTSTART"].dt == nxt + def test_safe_completion_issues_two_puts(self, monkeypatch: Any) -> None: """The standalone completed copy must not be PUT twice.