Skip to content

Commit 2ae46a5

Browse files
authored
fix a @scadenziario issue with recurrences (#323)
1 parent 1cc2d19 commit 2ae46a5

3 files changed

Lines changed: 161 additions & 0 deletions

File tree

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ Changelog
88
downstream packages can add extra fields to ``@scadenziario-day`` event
99
results without duplicating the whole endpoint.
1010
[fedevancin]
11+
- Fixed the ``@scadenziario`` endpoint in order to remove the first occurrence
12+
if it does not match any recurrence. ``plone.event`` implements a
13+
RFC5545 compliant system, so it always force-inject the first day by default.
14+
[fedevancin]
1115

1216

1317
6.3.16 (2026-04-07)

src/design/plone/contenttypes/restapi/services/scadenziario/post.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# -*- coding: utf-8 -*-
22
from datetime import timedelta
3+
from dateutil import rrule
34
from DateTime import DateTime
45
from pkg_resources import get_distribution
56
from pkg_resources import parse_version
@@ -11,6 +12,7 @@
1112
from plone.app.querystring import queryparser
1213
from plone.base.interfaces import IImageScalesAdapter
1314
from plone.event.interfaces import IEvent
15+
from plone.event.interfaces import IEventAccessor
1416
from plone.event.interfaces import IEventRecurrence
1517
from plone.event.interfaces import IRecurrenceSupport
1618
from plone.restapi.deserializer import json_body
@@ -34,6 +36,34 @@ def _to_pydate(value):
3436
return value.date()
3537

3638

39+
def _event_start_matches_own_rule(accessor):
40+
"""Whether an event's own start date is actually a valid occurrence of
41+
its recurrence rule.
42+
43+
plone.event.recurrence.recurrence_sequence_ical always force-includes
44+
the event's start date in the recurrence set (RFC5545 DTSTART
45+
semantics), even when it doesn't satisfy the RRULE itself, e.g. a
46+
FREQ=WEEKLY;BYDAY=MO,FR rule starting on a Thursday. We don't want that
47+
date to show up in the @scadenziario listing, so we recompute the rule
48+
on its own, without that forced inclusion, to check it.
49+
"""
50+
recrule = getattr(accessor, "recurrence", None)
51+
event_start = getattr(accessor, "start", None)
52+
if not recrule or not event_start:
53+
return True
54+
# dateutil refuses a tz-aware dtstart together with a tz-naive UNTIL
55+
# (as recurrence_sequence_ical produces one): mirror that function's
56+
# own workaround of stripping the tzinfo before parsing the rule.
57+
naive_start = event_start.replace(tzinfo=None)
58+
try:
59+
rset = rrule.rrulestr(
60+
recrule, dtstart=naive_start, forceset=True, ignoretz=True
61+
)
62+
except (ValueError, TypeError):
63+
return True
64+
return next(iter(rset), None) == naive_start
65+
66+
3767
class BaseService(Service):
3868
def expand_events(
3969
self, events, ret_mode, start=None, end=None, sort=None, sort_reverse=None
@@ -81,6 +111,13 @@ def expand_events(
81111
_obj_or_acc(occ, ret_mode)
82112
for occ in IRecurrenceSupport(obj).occurrences(start, end)
83113
]
114+
accessor = IEventAccessor(obj)
115+
if (
116+
occurrences
117+
and occurrences[0].start == accessor.start
118+
and not _event_start_matches_own_rule(accessor)
119+
):
120+
occurrences = occurrences[1:]
84121
elif IEvent.providedBy(obj):
85122
occurrences = [_obj_or_acc(obj, ret_mode)]
86123
else:

src/design/plone/contenttypes/tests/test_service_scadenziario.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# -*- coding: utf-8 -*-
22
from datetime import datetime
33
from datetime import timedelta
4+
from dateutil.rrule import rrulestr
45
from design.plone.contenttypes.testing import (
56
DESIGN_PLONE_CONTENTTYPES_API_FUNCTIONAL_TESTING,
67
)
@@ -91,6 +92,77 @@ def test_return_future_events_if_query_is_end_after_today(self):
9192
# results are in asc order
9293
self.assertEqual(response["items"], sorted(response["items"]))
9394

95+
def test_recurring_event_excludes_start_date_not_matching_byday_rule(self):
96+
# an event spanning multiple weeks
97+
# (e.g. from Thursday the 2nd to Friday the 17th) with a
98+
# recurrence limited to Mondays and Fridays. Since the event
99+
# itself starts on a Thursday, that start date does not satisfy
100+
# the BYDAY rule and must not be listed: only the actual
101+
# Monday/Friday occurrence dates should appear.
102+
# See https://github.com/plone/plone.event RFC5545 DTSTART handling:
103+
# DTSTART is always part of the recurrence set even if it doesn't
104+
# match the rule, but that's not what we want to expose here.
105+
# fixed reference date (a Thursday), so the test doesn't depend on
106+
# the day it happens to run.
107+
start = datetime(2024, 4, 4, 9, 0)
108+
end = start + timedelta(hours=1)
109+
# spans multiple weeks, like the reported "from the 2nd to the
110+
# 17th" case (15 days, i.e. more than two full weeks).
111+
until = start + timedelta(days=15)
112+
recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO,FR;UNTIL={}".format(
113+
until.strftime("%Y%m%dT235959")
114+
)
115+
116+
api.content.create(
117+
container=self.portal,
118+
type="Event",
119+
title="Recurring event starting on a non-matching weekday",
120+
start=start,
121+
end=end,
122+
recurrence=recurrence,
123+
)
124+
commit()
125+
126+
# what the RRULE alone actually produces, ignoring the forced
127+
# DTSTART inclusion: this is what @scadenziario should return.
128+
expected_days = sorted(
129+
{
130+
occurrence.strftime("%Y/%m/%d")
131+
for occurrence in rrulestr(
132+
recurrence, dtstart=start, forceset=True, ignoretz=True
133+
)
134+
}
135+
)
136+
start_day = start.strftime("%Y/%m/%d")
137+
# sanity checks on the fixture itself: multiple weeks, several
138+
# occurrences, and the (non-matching) start day genuinely absent.
139+
self.assertGreaterEqual(len(expected_days), 5)
140+
self.assertNotIn(start_day, expected_days)
141+
142+
response = self.api_session.post(
143+
f"{self.portal_url}/@scadenziario",
144+
json={
145+
"query": [
146+
{
147+
"i": "portal_type",
148+
"o": "plone.app.querystring.operation.selection.any",
149+
"v": ["Event"],
150+
},
151+
{
152+
"i": "path",
153+
"o": "plone.app.querystring.operation.string.relativePath",
154+
"v": "./",
155+
},
156+
],
157+
"sort_on": "start",
158+
"sort_order": "ascending",
159+
"b_size": 100,
160+
},
161+
).json()
162+
163+
self.assertEqual(response["items"], expected_days)
164+
self.assertNotIn(start_day, response["items"])
165+
94166

95167
class ScadenziarioDayTest(unittest.TestCase):
96168
layer = DESIGN_PLONE_CONTENTTYPES_API_FUNCTIONAL_TESTING
@@ -234,3 +306,51 @@ def test_recurring_event_uses_occurrence_dates_not_master_span(self):
234306
for item in day_response["items"].get(day_after_first_occurrence, [])
235307
]
236308
self.assertNotIn("Recurring event", titles)
309+
310+
def test_recurring_event_excludes_start_date_not_matching_byday_rule(self):
311+
# same bug and fixture as in ScadenziarioTest (event spanning
312+
# multiple weeks, e.g. from Thursday the 2nd to Friday the 17th,
313+
# recurring only on Mondays and Fridays), but checked against
314+
# @scadenziario-day: it must not be found on its own (non-matching)
315+
# Thursday start date, only on the actual Monday/Friday occurrences.
316+
# fixed reference date (a Thursday), so the test doesn't depend on
317+
# the day it happens to run.
318+
start = datetime(2024, 4, 4, 9, 0)
319+
end = start + timedelta(hours=1)
320+
# spans multiple weeks, like the reported "from the 2nd to the
321+
# 17th" case (15 days, i.e. more than two full weeks).
322+
until = start + timedelta(days=15)
323+
recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO,FR;UNTIL={}".format(
324+
until.strftime("%Y%m%dT235959")
325+
)
326+
327+
api.content.create(
328+
container=self.portal,
329+
type="Event",
330+
title="Recurring event starting on a non-matching weekday",
331+
start=start,
332+
end=end,
333+
recurrence=recurrence,
334+
)
335+
commit()
336+
337+
occurrence_days = {
338+
occurrence.strftime("%Y/%m/%d")
339+
for occurrence in rrulestr(
340+
recurrence, dtstart=start, forceset=True, ignoretz=True
341+
)
342+
}
343+
start_day = start.strftime("%Y/%m/%d")
344+
self.assertGreaterEqual(len(occurrence_days), 5)
345+
self.assertNotIn(start_day, occurrence_days)
346+
347+
for occurrence_day in occurrence_days:
348+
day_response = self.query_day(datetime.strptime(occurrence_day, "%Y/%m/%d"))
349+
titles = [
350+
item["title"] for item in day_response["items"].get(occurrence_day, [])
351+
]
352+
self.assertIn("Recurring event starting on a non-matching weekday", titles)
353+
354+
day_response = self.query_day(start)
355+
titles = [item["title"] for item in day_response["items"].get(start_day, [])]
356+
self.assertNotIn("Recurring event starting on a non-matching weekday", titles)

0 commit comments

Comments
 (0)