From 7cd918d036db0ef4e8db9b435faa535433bf7373 Mon Sep 17 00:00:00 2001 From: Georgiy Date: Mon, 21 Jul 2025 20:58:58 +0300 Subject: [PATCH] (#1812) Fixed an issue where non-recurring events were not included in the response when requesting an expand report --- radicale/app/report.py | 101 ++++++++++---------- radicale/tests/static/event_issue1812_2.ics | 31 ++++++ radicale/tests/static/event_issue1812_3.ics | 31 ++++++ radicale/tests/test_expand.py | 7 +- 4 files changed, 120 insertions(+), 50 deletions(-) create mode 100644 radicale/tests/static/event_issue1812_2.ics create mode 100644 radicale/tests/static/event_issue1812_3.ics diff --git a/radicale/app/report.py b/radicale/app/report.py index 8cb1e9b2..880b0c59 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -360,12 +360,12 @@ def _expand( # Split the vevents included in the component into one that contains the # recurrence information and others that contain a recurrence id to # override instances. - vevent_recurrence, vevents_overridden = _split_overridden_vevents(vevent_component) + base_vevent, vevents_overridden = _split_overridden_vevents(vevent_component) dt_format = '%Y%m%dT%H%M%SZ' all_day_event = False - if type(vevent_recurrence.dtstart.value) is datetime.date: + if type(base_vevent.dtstart.value) is datetime.date: # If an event comes to us with a dtstart specified as a date # then in the response we return the date, not datetime dt_format = '%Y%m%d' @@ -382,11 +382,11 @@ def _expand( _strip_single_event(vevent, dt_format) duration = None - if hasattr(vevent_recurrence, "dtend"): - duration = vevent_recurrence.dtend.value - vevent_recurrence.dtstart.value - elif hasattr(vevent_recurrence, "duration"): + if hasattr(base_vevent, "dtend"): + duration = base_vevent.dtend.value - base_vevent.dtstart.value + elif hasattr(base_vevent, "duration"): try: - duration = vevent_recurrence.duration.value + duration = base_vevent.duration.value if duration.total_seconds() <= 0: logger.warning("Invalid DURATION: %s", duration) duration = None @@ -396,8 +396,8 @@ def _expand( # Generate EXDATE to remove from expansion range exdates_set: set[datetime.datetime] = set() - if hasattr(vevent_recurrence, 'exdate'): - exdates = vevent_recurrence.exdate.value + if hasattr(base_vevent, 'exdate'): + exdates = base_vevent.exdate.value if not isinstance(exdates, list): exdates = [exdates] @@ -409,9 +409,14 @@ def _expand( logger.debug("EXDATE values: %s", exdates_set) + events_for_filtering = vevents_overridden + rruleset = None - if hasattr(vevent_recurrence, 'rrule'): - rruleset = vevent_recurrence.getrruleset() + if hasattr(base_vevent, 'rrule'): + rruleset = base_vevent.getrruleset() + else: + # if event does not have rrule, only include base event + events_for_filtering = [base_vevent] filtered_vevents = [] if rruleset: @@ -436,7 +441,7 @@ def _expand( .format(max_occurrence)) _strip_component(vevent_component) - _strip_single_event(vevent_recurrence, dt_format) + _strip_single_event(base_vevent, dt_format) i_overridden = 0 @@ -463,7 +468,7 @@ def _expand( if not vevent: # Create new instance from recurrence - vevent = copy.deepcopy(vevent_recurrence) + vevent = copy.deepcopy(base_vevent) # For all day events, the system timezone may influence the # results, so use recurrence_dt @@ -488,45 +493,45 @@ def _expand( filtered_vevents.append(vevent) - # Filter overridden and recurrence base events - if time_range_start is not None and time_range_end is not None: - for vevent in vevents_overridden: - dtstart = vevent.dtstart.value + # Filter overridden and non-recurring events + if time_range_start is not None and time_range_end is not None: + for vevent in events_for_filtering: + dtstart = vevent.dtstart.value - # Handle string values for DTSTART/DTEND - if isinstance(dtstart, str): - try: - dtstart = datetime.datetime.strptime(dtstart, dt_format) - if all_day_event: - dtstart = dtstart.date() - except ValueError as e: - logger.warning("Invalid DTSTART format: %s, error: %s", dtstart, e) - continue - - dtend = dtstart + duration if duration else dtstart - - logger.debug( - "Filtering VEVENT with DTSTART: %s (type: %s), DTEND: %s (type: %s)", - dtstart, type(dtstart), dtend, type(dtend)) - - # Convert to datetime for comparison - if all_day_event and isinstance(dtstart, datetime.date) and not isinstance(dtstart, datetime.datetime): - dtstart = datetime.datetime.fromordinal(dtstart.toordinal()).replace(tzinfo=None) - dtend = datetime.datetime.fromordinal(dtend.toordinal()).replace(tzinfo=None) - elif not all_day_event and isinstance(dtstart, datetime.datetime) \ - and isinstance(dtend, datetime.datetime): - dtstart = dtstart.replace(tzinfo=datetime.timezone.utc) - dtend = dtend.replace(tzinfo=datetime.timezone.utc) - else: - logger.warning("Unexpected DTSTART/DTEND type: dtstart=%s, dtend=%s", type(dtstart), type(dtend)) + # Handle string values for DTSTART/DTEND + if isinstance(dtstart, str): + try: + dtstart = datetime.datetime.strptime(dtstart, dt_format) + if all_day_event: + dtstart = dtstart.date() + except ValueError as e: + logger.warning("Invalid DTSTART format: %s, error: %s", dtstart, e) continue - if dtstart < time_range_end and dtend > time_range_start: - if vevent not in filtered_vevents: # Avoid duplicates - logger.debug("VEVENT passed time-range filter: %s", dtstart) - filtered_vevents.append(vevent) - else: - logger.debug("VEVENT filtered out: %s", dtstart) + dtend = dtstart + duration if duration else dtstart + + logger.debug( + "Filtering VEVENT with DTSTART: %s (type: %s), DTEND: %s (type: %s)", + dtstart, type(dtstart), dtend, type(dtend)) + + # Convert to datetime for comparison + if all_day_event and isinstance(dtstart, datetime.date) and not isinstance(dtstart, datetime.datetime): + dtstart = datetime.datetime.fromordinal(dtstart.toordinal()).replace(tzinfo=None) + dtend = datetime.datetime.fromordinal(dtend.toordinal()).replace(tzinfo=None) + elif not all_day_event and isinstance(dtstart, datetime.datetime) \ + and isinstance(dtend, datetime.datetime): + dtstart = dtstart.replace(tzinfo=datetime.timezone.utc) + dtend = dtend.replace(tzinfo=datetime.timezone.utc) + else: + logger.warning("Unexpected DTSTART/DTEND type: dtstart=%s, dtend=%s", type(dtstart), type(dtend)) + continue + + if dtstart < time_range_end and dtend > time_range_start: + if vevent not in filtered_vevents: # Avoid duplicates + logger.debug("VEVENT passed time-range filter: %s", dtstart) + filtered_vevents.append(vevent) + else: + logger.debug("VEVENT filtered out: %s", dtstart) # Rebuild component diff --git a/radicale/tests/static/event_issue1812_2.ics b/radicale/tests/static/event_issue1812_2.ics new file mode 100644 index 00000000..8273e93f --- /dev/null +++ b/radicale/tests/static/event_issue1812_2.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN +BEGIN:VTIMEZONE +TZID:Europe/Paris +LAST-MODIFIED:20250523T094234Z +BEGIN:STANDARD +DTSTART:19701025T030000Z +RRULE:BYDAY=-1SU;BYMONTH=10;FREQ=YEARLY +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700329T020000Z +RRULE:BYDAY=-1SU;BYMONTH=3;FREQ=YEARLY +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:a07cfa8b-0ce6-4956-800d-c0bfe1f0730a +DTSTART;TZID=Europe/Paris;VALUE=DATE:20250716 +DTEND;TZID=Europe/Paris;VALUE=DATE:20250718 +DTSTAMP;VALUE=DATE-TIME:20250721T075355Z +RRULE:FREQ=WEEKLY +SEQUENCE:1 +SUMMARY:bla +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/radicale/tests/static/event_issue1812_3.ics b/radicale/tests/static/event_issue1812_3.ics new file mode 100644 index 00000000..dfb0217b --- /dev/null +++ b/radicale/tests/static/event_issue1812_3.ics @@ -0,0 +1,31 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN +BEGIN:VTIMEZONE +TZID:Europe/Paris +LAST-MODIFIED:20250523T094234Z +BEGIN:STANDARD +DTSTART:19701025T030000Z +RRULE:BYDAY=-1SU;BYMONTH=10;FREQ=YEARLY +TZNAME:CET +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19700329T020000Z +RRULE:BYDAY=-1SU;BYMONTH=3;FREQ=YEARLY +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:c6be8b2c-3d72-453c-b698-4f25cdf1569e +DTSTART;TZID=Europe/Paris;VALUE=DATE-TIME:20250716T110000 +DTEND;TZID=Europe/Paris;VALUE=DATE-TIME:20250716T120000 +ATTENDEE;CN=Corentin;ROLE=REQ-PARTICIPANT:MAILTO:corentin.jeanne@algoo.fr +DTSTAMP:20250718T151312Z +ORGANIZER;CN=Sigma:MAILTO:lambda@lambda.lambda +SUMMARY:Test mail notifications 2 +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/radicale/tests/test_expand.py b/radicale/tests/test_expand.py index 0b3dca01..2cc4a49f 100644 --- a/radicale/tests/test_expand.py +++ b/radicale/tests/test_expand.py @@ -474,8 +474,8 @@ permissions: RrWw""") self.put("/test/event.ics/", get_file_content("event_issue1812_2.ics")) self.put("/test/event2.ics/", get_file_content("event_issue1812_3.ics")) - request = f""" - + request = """ + @@ -504,6 +504,9 @@ permissions: RrWw""") status, event1_calendar_data = responses["/test/event.ics"]["C:calendar-data"] assert event1_calendar_data.text assert "UID:a07cfa8b-0ce6-4956-800d-c0bfe1f0730a" in event1_calendar_data.text + assert "RECURRENCE-ID:20250716" in event1_calendar_data.text + assert "RECURRENCE-ID:20250723" in event1_calendar_data.text + assert "RECURRENCE-ID:20250730" in event1_calendar_data.text assert "C:calendar-data" in responses["/test/event2.ics"] status, event2_calendar_data = responses["/test/event2.ics"]["C:calendar-data"]