From 94c01c4242b6220bcc5bea92ce9c0912afb1f48e Mon Sep 17 00:00:00 2001 From: David Venhoff Date: Fri, 6 Dec 2024 14:23:29 +0100 Subject: [PATCH] Parse recurrence rules with `BYDAY` entries with frequency For example, `-1SU`. Unfortunately this commit contains a manual parsing implementation, because the `icalendar` package does not support these right now. (Though it will at the next update) --- .../cms/utils/external_calendar_utils.py | 59 ++++++++++++++++++- ...ce_rule_conflicting_byday_and_bysetpos.ics | 23 ++++++++ ...rrence_rule_multiple_byday_frequencies.ics | 23 ++++++++ .../cms/utils/test_external_calendar_utils.py | 8 +++ .../assets/calendars/recurrence_rules.ics | 12 ++++ .../management/commands/test_import_events.py | 2 + 6 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 tests/cms/utils/assets/calendars/invalid_recurrence_rule_conflicting_byday_and_bysetpos.ics create mode 100644 tests/cms/utils/assets/calendars/invalid_recurrence_rule_multiple_byday_frequencies.ics diff --git a/integreat_cms/cms/utils/external_calendar_utils.py b/integreat_cms/cms/utils/external_calendar_utils.py index 0869a8e7d2..01f27cdd69 100644 --- a/integreat_cms/cms/utils/external_calendar_utils.py +++ b/integreat_cms/cms/utils/external_calendar_utils.py @@ -7,6 +7,7 @@ import dataclasses import datetime import logging +import re from typing import Any, Self import icalendar.cal @@ -27,6 +28,11 @@ from integreat_cms.cms.models import EventTranslation, ExternalCalendar, RecurrenceRule from integreat_cms.cms.utils.content_utils import clean_content +# Copied from https://github.com/collective/icalendar/blob/4725c1bd57ca3afe61e7d45805a2b337842fbd29/src/icalendar/prop.py#L68-L69 +WEEKDAY_RULE = re.compile( + r"(?P[+-]?)(?P[\d]{0,2})(?P[\w]{2})$" +) + @dataclasses.dataclass(frozen=True, kw_only=True) class ImportResult: @@ -184,12 +190,13 @@ class RecurrenceRuleData: interval: vInt until: vDDDTypes | None by_day: list[vWeekday] | None - by_set_pos: vInt | None + by_set_pos: int | None @classmethod def from_ical_rrule( cls, recurrence_rule: vRecur, start: datetime.date, logger: logging.Logger ) -> Self: + # pylint: disable=too-many-locals """ Constructs this class from an ical recurrence rule. :return: An instance of this class @@ -217,6 +224,26 @@ def pop_single_value(name: str, *, required: bool = False) -> Any: by_day = recurrence_rule.pop("BYDAY") by_set_pos = pop_single_value("BYSETPOS") + # by_set_pos can also be specified in `by_day`. We don't support multiple days with `by_set_pos` right now, though. + if by_day and len(by_day) == 1: + set_pos, weekday = _parse_weekday(by_day[0]) + if by_set_pos is not None and set_pos is not None: + raise ValueError( + f"Conflicting `BYSETPOS` and `BYDAY`: {by_set_pos} and {by_day}" + ) + by_day[0] = weekday + by_set_pos = set_pos or by_set_pos + elif by_day: + updated_days = [] + for day in by_day: + set_pos, weekday = _parse_weekday(day) + updated_days.append(weekday) + if set_pos is not None: + raise ValueError( + f"Cannot support multiple days with frequency right now: {by_day}" + ) + by_day = updated_days + # WKST currently always has to be monday (or unset, because it defaults do monday) if (wkst := pop_single_value("WKST")) and wkst.lower() != "mo": raise ValueError( @@ -311,6 +338,30 @@ def decode_by_day(self) -> list[int]: return weekdays +def _parse_weekday(weekdaynum: str) -> tuple[int | None, str]: + """ + Parses a weekday according to https://www.rfc-editor.org/rfc/rfc5545#section-3.3.10 (see `weekdaynum`) + TODO: This should be handled by our icalendar package. Remove this code when a new version of icalendar is released. + :param weekdaynum: The weekday + frequency number + :return: A tuple of frequency and weekday, where the frequency is None if the weekdaynum only contained a weekday. + """ + match = WEEKDAY_RULE.match(weekdaynum) + assert match is not None + sign = match["signal"] + ordinal_week_number = match["relative"] + weekday = match["weekday"] + if not ordinal_week_number: + return None, weekday + ordinal_week_number = int(ordinal_week_number) + match sign: + case "-": + return -ordinal_week_number, weekday + case "+" | "": + return ordinal_week_number, weekday + case _: + return None, weekday + + def import_events(calendar: ExternalCalendar, logger: logging.Logger) -> ImportResult: """ Imports events from this calendar and sets or clears the errors field of the calendar @@ -452,7 +503,11 @@ def import_event( try: recurrence_rule_form_data = event_data.to_recurrence_rule_form_data() except ValueError as e: - logger.error("Could not import event due to unsupported recurrence rule: %r", e) + logger.error( + "Could not import event due to unsupported recurrence rule: %r\n%s", + e, + event_data, + ) errors.append( _("Could not import '{}': Unsupported recurrence rule").format( event_data.title, e diff --git a/tests/cms/utils/assets/calendars/invalid_recurrence_rule_conflicting_byday_and_bysetpos.ics b/tests/cms/utils/assets/calendars/invalid_recurrence_rule_conflicting_byday_and_bysetpos.ics new file mode 100644 index 0000000000..02f30dc80d --- /dev/null +++ b/tests/cms/utils/assets/calendars/invalid_recurrence_rule_conflicting_byday_and_bysetpos.ics @@ -0,0 +1,23 @@ +BEGIN:VEVENT +RRULE:FREQ=MONTHLY;UNTIL=20271231T230000Z;INTERVAL=1;BYDAY=2WE;BYSETPOS=3 +UID:2ecade0f-3f6c-4094-96a5-fd73fce5c773 +SUMMARY:Monthly? +DTSTART;VALUE=DATE:20250101 +DTEND;VALUE=DATE:20250102 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20241119T134045Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:2 +X-MICROSOFT-CDO-APPT-SEQUENCE:2 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:TRUE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:1 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:FALSE +END:VEVENT diff --git a/tests/cms/utils/assets/calendars/invalid_recurrence_rule_multiple_byday_frequencies.ics b/tests/cms/utils/assets/calendars/invalid_recurrence_rule_multiple_byday_frequencies.ics new file mode 100644 index 0000000000..3a4ae8ebba --- /dev/null +++ b/tests/cms/utils/assets/calendars/invalid_recurrence_rule_multiple_byday_frequencies.ics @@ -0,0 +1,23 @@ +BEGIN:VEVENT +RRULE:FREQ=MONTHLY;UNTIL=20271231T230000Z;BYDAY=2WE,3TH +UID:2ecade0f-3f6c-4094-96a5-fd73fce5c773 +SUMMARY:Monthly every second Wednesday and third Thursday +DTSTART;VALUE=DATE:20250101 +DTEND;VALUE=DATE:20250102 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20241119T134045Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:2 +X-MICROSOFT-CDO-APPT-SEQUENCE:2 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:TRUE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:1 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:FALSE +END:VEVENT diff --git a/tests/cms/utils/test_external_calendar_utils.py b/tests/cms/utils/test_external_calendar_utils.py index 7d8d3a6a37..78c2f740eb 100644 --- a/tests/cms/utils/test_external_calendar_utils.py +++ b/tests/cms/utils/test_external_calendar_utils.py @@ -22,6 +22,14 @@ "tests/cms/utils/assets/calendars/invalid_recurrence_rule_bymonthday.ics", r"Month day of recurrence rule does not match month day of event: 3 and 1", ), + ( + "tests/cms/utils/assets/calendars/invalid_recurrence_rule_conflicting_byday_and_bysetpos.ics", + r"Conflicting `BYSETPOS` and `BYDAY`: 3 and \['2WE'\]", + ), + ( + "tests/cms/utils/assets/calendars/invalid_recurrence_rule_multiple_byday_frequencies.ics", + r"Cannot support multiple days with frequency right now: \['2WE', '3TH'\]", + ), ] diff --git a/tests/core/management/commands/assets/calendars/recurrence_rules.ics b/tests/core/management/commands/assets/calendars/recurrence_rules.ics index dd422ed1a4..e1a13163af 100644 --- a/tests/core/management/commands/assets/calendars/recurrence_rules.ics +++ b/tests/core/management/commands/assets/calendars/recurrence_rules.ics @@ -83,4 +83,16 @@ STATUS:CONFIRMED SUMMARY:Every year until 2034 RRULE:FREQ=YEARLY;BYMONTH=11;UNTIL=20341112T230000Z END:VEVENT +BEGIN:VEVENT +CREATED:20241113T130016Z +DTSTAMP:20241113T130159Z +LAST-MODIFIED:20241113T130159Z +SEQUENCE:2 +UID:6d6abd00-27af-4eff-868f-396ba9cd7013 +DTSTART;VALUE=DATE:20241113 +DTEND;VALUE=DATE:20241114 +STATUS:CONFIRMED +SUMMARY:Every 3rd Wednesday +RRULE:FREQ=MONTHLY;BYDAY=3WE +END:VEVENT END:VCALENDAR diff --git a/tests/core/management/commands/test_import_events.py b/tests/core/management/commands/test_import_events.py index 548683f4fd..39252a9d99 100644 --- a/tests/core/management/commands/test_import_events.py +++ b/tests/core/management/commands/test_import_events.py @@ -55,12 +55,14 @@ "Every second day", "Weekly event", "Every year until 2034", + "Every 3rd Wednesday", ], { "DTSTART:20241112T230000\nRRULE:FREQ=MONTHLY;BYDAY=+1MO", "DTSTART:20241113T130002\nRRULE:FREQ=DAILY;INTERVAL=2", "DTSTART:20241113T190000\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR", "DTSTART:20241112T230000\nRRULE:FREQ=YEARLY;UNTIL=20341112T235959", + "DTSTART:20241112T230000\nRRULE:FREQ=MONTHLY;BYDAY=+3WE", }, ), (