From e40e16754f654aff81c19d7bbc93f11f109a0247 Mon Sep 17 00:00:00 2001 From: Federico Vancini Date: Tue, 14 Jul 2026 16:20:31 +0200 Subject: [PATCH 1/2] fix a @scadenziario issue with recurrences --- CHANGES.rst | 4 +++ .../restapi/services/scadenziario/post.py | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index c70cf700..8974fa8c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,10 @@ Changelog downstream packages can add extra fields to ``@scadenziario-day`` event results without duplicating the whole endpoint. [fedevancin] +- Fixed the ``@scadenziario`` endpoint in order to remove the first occurrence + if it does not match any recurrence. ``plone.event`` implements a + RFC5545 compliant system, so it always force-inject the first day by default. + [fedevancin] 6.3.16 (2026-04-07) diff --git a/src/design/plone/contenttypes/restapi/services/scadenziario/post.py b/src/design/plone/contenttypes/restapi/services/scadenziario/post.py index facfe888..115b46f6 100644 --- a/src/design/plone/contenttypes/restapi/services/scadenziario/post.py +++ b/src/design/plone/contenttypes/restapi/services/scadenziario/post.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from datetime import timedelta +from dateutil import rrule from DateTime import DateTime from pkg_resources import get_distribution from pkg_resources import parse_version @@ -11,6 +12,7 @@ from plone.app.querystring import queryparser from plone.base.interfaces import IImageScalesAdapter from plone.event.interfaces import IEvent +from plone.event.interfaces import IEventAccessor from plone.event.interfaces import IEventRecurrence from plone.event.interfaces import IRecurrenceSupport from plone.restapi.deserializer import json_body @@ -34,6 +36,30 @@ def _to_pydate(value): return value.date() +def _event_start_matches_own_rule(accessor): + """Whether an event's own start date is actually a valid occurrence of + its recurrence rule. + + plone.event.recurrence.recurrence_sequence_ical always force-includes + the event's start date in the recurrence set (RFC5545 DTSTART + semantics), even when it doesn't satisfy the RRULE itself, e.g. a + FREQ=WEEKLY;BYDAY=MO,FR rule starting on a Thursday. We don't want that + date to show up in the @scadenziario listing, so we recompute the rule + on its own, without that forced inclusion, to check it. + """ + recrule = getattr(accessor, "recurrence", None) + event_start = getattr(accessor, "start", None) + if not recrule or not event_start: + return True + try: + rset = rrule.rrulestr( + recrule, dtstart=event_start, forceset=True, ignoretz=True + ) + except (ValueError, TypeError): + return True + return next(iter(rset), None) == event_start + + class BaseService(Service): def expand_events( self, events, ret_mode, start=None, end=None, sort=None, sort_reverse=None @@ -81,6 +107,13 @@ def expand_events( _obj_or_acc(occ, ret_mode) for occ in IRecurrenceSupport(obj).occurrences(start, end) ] + accessor = IEventAccessor(obj) + if ( + occurrences + and occurrences[0].start == accessor.start + and not _event_start_matches_own_rule(accessor) + ): + occurrences = occurrences[1:] elif IEvent.providedBy(obj): occurrences = [_obj_or_acc(obj, ret_mode)] else: From c58c743aa45c1e60655ca9b22edc613985eb2693 Mon Sep 17 00:00:00 2001 From: Federico Vancini Date: Wed, 15 Jul 2026 09:15:10 +0200 Subject: [PATCH 2/2] add tests --- .../restapi/services/scadenziario/post.py | 8 +- .../tests/test_service_scadenziario.py | 120 ++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/design/plone/contenttypes/restapi/services/scadenziario/post.py b/src/design/plone/contenttypes/restapi/services/scadenziario/post.py index 115b46f6..80465a7e 100644 --- a/src/design/plone/contenttypes/restapi/services/scadenziario/post.py +++ b/src/design/plone/contenttypes/restapi/services/scadenziario/post.py @@ -51,13 +51,17 @@ def _event_start_matches_own_rule(accessor): event_start = getattr(accessor, "start", None) if not recrule or not event_start: return True + # dateutil refuses a tz-aware dtstart together with a tz-naive UNTIL + # (as recurrence_sequence_ical produces one): mirror that function's + # own workaround of stripping the tzinfo before parsing the rule. + naive_start = event_start.replace(tzinfo=None) try: rset = rrule.rrulestr( - recrule, dtstart=event_start, forceset=True, ignoretz=True + recrule, dtstart=naive_start, forceset=True, ignoretz=True ) except (ValueError, TypeError): return True - return next(iter(rset), None) == event_start + return next(iter(rset), None) == naive_start class BaseService(Service): diff --git a/src/design/plone/contenttypes/tests/test_service_scadenziario.py b/src/design/plone/contenttypes/tests/test_service_scadenziario.py index 0fba560f..bc7b3846 100644 --- a/src/design/plone/contenttypes/tests/test_service_scadenziario.py +++ b/src/design/plone/contenttypes/tests/test_service_scadenziario.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from datetime import datetime from datetime import timedelta +from dateutil.rrule import rrulestr from design.plone.contenttypes.testing import ( DESIGN_PLONE_CONTENTTYPES_API_FUNCTIONAL_TESTING, ) @@ -91,6 +92,77 @@ def test_return_future_events_if_query_is_end_after_today(self): # results are in asc order self.assertEqual(response["items"], sorted(response["items"])) + def test_recurring_event_excludes_start_date_not_matching_byday_rule(self): + # an event spanning multiple weeks + # (e.g. from Thursday the 2nd to Friday the 17th) with a + # recurrence limited to Mondays and Fridays. Since the event + # itself starts on a Thursday, that start date does not satisfy + # the BYDAY rule and must not be listed: only the actual + # Monday/Friday occurrence dates should appear. + # See https://github.com/plone/plone.event RFC5545 DTSTART handling: + # DTSTART is always part of the recurrence set even if it doesn't + # match the rule, but that's not what we want to expose here. + # fixed reference date (a Thursday), so the test doesn't depend on + # the day it happens to run. + start = datetime(2024, 4, 4, 9, 0) + end = start + timedelta(hours=1) + # spans multiple weeks, like the reported "from the 2nd to the + # 17th" case (15 days, i.e. more than two full weeks). + until = start + timedelta(days=15) + recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO,FR;UNTIL={}".format( + until.strftime("%Y%m%dT235959") + ) + + api.content.create( + container=self.portal, + type="Event", + title="Recurring event starting on a non-matching weekday", + start=start, + end=end, + recurrence=recurrence, + ) + commit() + + # what the RRULE alone actually produces, ignoring the forced + # DTSTART inclusion: this is what @scadenziario should return. + expected_days = sorted( + { + occurrence.strftime("%Y/%m/%d") + for occurrence in rrulestr( + recurrence, dtstart=start, forceset=True, ignoretz=True + ) + } + ) + start_day = start.strftime("%Y/%m/%d") + # sanity checks on the fixture itself: multiple weeks, several + # occurrences, and the (non-matching) start day genuinely absent. + self.assertGreaterEqual(len(expected_days), 5) + self.assertNotIn(start_day, expected_days) + + response = self.api_session.post( + f"{self.portal_url}/@scadenziario", + json={ + "query": [ + { + "i": "portal_type", + "o": "plone.app.querystring.operation.selection.any", + "v": ["Event"], + }, + { + "i": "path", + "o": "plone.app.querystring.operation.string.relativePath", + "v": "./", + }, + ], + "sort_on": "start", + "sort_order": "ascending", + "b_size": 100, + }, + ).json() + + self.assertEqual(response["items"], expected_days) + self.assertNotIn(start_day, response["items"]) + class ScadenziarioDayTest(unittest.TestCase): layer = DESIGN_PLONE_CONTENTTYPES_API_FUNCTIONAL_TESTING @@ -234,3 +306,51 @@ def test_recurring_event_uses_occurrence_dates_not_master_span(self): for item in day_response["items"].get(day_after_first_occurrence, []) ] self.assertNotIn("Recurring event", titles) + + def test_recurring_event_excludes_start_date_not_matching_byday_rule(self): + # same bug and fixture as in ScadenziarioTest (event spanning + # multiple weeks, e.g. from Thursday the 2nd to Friday the 17th, + # recurring only on Mondays and Fridays), but checked against + # @scadenziario-day: it must not be found on its own (non-matching) + # Thursday start date, only on the actual Monday/Friday occurrences. + # fixed reference date (a Thursday), so the test doesn't depend on + # the day it happens to run. + start = datetime(2024, 4, 4, 9, 0) + end = start + timedelta(hours=1) + # spans multiple weeks, like the reported "from the 2nd to the + # 17th" case (15 days, i.e. more than two full weeks). + until = start + timedelta(days=15) + recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO,FR;UNTIL={}".format( + until.strftime("%Y%m%dT235959") + ) + + api.content.create( + container=self.portal, + type="Event", + title="Recurring event starting on a non-matching weekday", + start=start, + end=end, + recurrence=recurrence, + ) + commit() + + occurrence_days = { + occurrence.strftime("%Y/%m/%d") + for occurrence in rrulestr( + recurrence, dtstart=start, forceset=True, ignoretz=True + ) + } + start_day = start.strftime("%Y/%m/%d") + self.assertGreaterEqual(len(occurrence_days), 5) + self.assertNotIn(start_day, occurrence_days) + + for occurrence_day in occurrence_days: + day_response = self.query_day(datetime.strptime(occurrence_day, "%Y/%m/%d")) + titles = [ + item["title"] for item in day_response["items"].get(occurrence_day, []) + ] + self.assertIn("Recurring event starting on a non-matching weekday", titles) + + day_response = self.query_day(start) + titles = [item["title"] for item in day_response["items"].get(start_day, [])] + self.assertNotIn("Recurring event starting on a non-matching weekday", titles)