Skip to content

Commit

Permalink
Parse recurrence rules with BYDAY entries with frequency
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
david-venhoff committed Dec 11, 2024
1 parent 9d27598 commit 59aa0bd
Show file tree
Hide file tree
Showing 6 changed files with 125 additions and 2 deletions.
59 changes: 57 additions & 2 deletions integreat_cms/cms/utils/external_calendar_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import dataclasses
import datetime
import logging
import re
from typing import Any, Self

import icalendar.cal
Expand All @@ -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<signal>[+-]?)(?P<relative>[\d]{0,2})(?P<weekday>[\w]{2})$"
)


@dataclasses.dataclass(frozen=True, kw_only=True)
class IcalEventData:
Expand Down Expand Up @@ -175,12 +181,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
Expand Down Expand Up @@ -208,6 +215,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(
Expand Down Expand Up @@ -302,6 +329,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"]
relative = match["relative"]
weekday = match["weekday"]
if not relative:
return None, weekday
relative = int(relative)
match sign:
case "-":
return -relative, weekday
case "+" | "":
return relative, weekday
case _:
return None, weekday


def import_events(calendar: ExternalCalendar, logger: logging.Logger) -> None:
"""
Imports events from this calendar and sets or clears the errors field of the calendar
Expand Down Expand Up @@ -442,7 +493,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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions tests/cms/utils/test_external_calendar_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'\]",
),
]


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions tests/core/management/commands/test_import_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
),
(
Expand Down

0 comments on commit 59aa0bd

Please sign in to comment.