Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -34,6 +36,34 @@ 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
# 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=naive_start, forceset=True, ignoretz=True
)
except (ValueError, TypeError):
return True
return next(iter(rset), None) == naive_start


class BaseService(Service):
def expand_events(
self, events, ret_mode, start=None, end=None, sort=None, sort_reverse=None
Expand Down Expand Up @@ -81,6 +111,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:
Expand Down
120 changes: 120 additions & 0 deletions src/design/plone/contenttypes/tests/test_service_scadenziario.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading