Просмотр исходного кода

(#1812) Fixed an issue where non-recurring events were not included in
the response when requesting an expand report

Georgiy 7 месяцев назад
Родитель
Сommit
7cd918d036

+ 53 - 48
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
-
-                # 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
+    # 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
 
-                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
 

+ 31 - 0
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

+ 31 - 0
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

+ 5 - 2
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"""
-            <c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:ca="http://apple.com/ns/ical/" xmlns:d="DAV:">
+        request = """
+            <c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
             <d:prop>
                 <d:getetag/>
                 <c:calendar-data>
@@ -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"]