Repository: uqcomputingsociety/uqcsbot-slack
Issue: !events Not Displaying Repeating Events

As per title, repeating events are not appearing when `!events all` is given for example. 

Changed files:
test/test_events.py, uqcsbot/scripts/events.py

diff --git a/test/test_events.py b/test/test_events.py
index ac6ab5f..cf0406a 100644
--- a/test/test_events.py
+++ b/test/test_events.py
@@ -69,6 +69,12 @@ def mocked_events_ics(source: str = "uqcs") -> bytes:
     with open("test/test_events_events.ics", "rb") as events_file:
         return events_file.read()
 
+def mocked_get_august_2018_time():
+    """
+    Returns a fixed datetime at the start of August 2018.
+    (Unline mocked_get_august_time the week after this date has a recurring event)
+    """
+    return datetime(2018, 8, 1, tzinfo=BRISBANE_TZ).astimezone(utc)
 
 def mocked_get_august_time():
     """
@@ -314,3 +320,18 @@ def test_events_filter_itee(uqcsbot: MockUQCSBot):
     assert len(messages) == 2
     expected = "_Events in the next *2 weeks*:_"
     assert messages[1].get('text') == expected
+
+@patch("uqcsbot.utils.itee_seminar_utils.get_seminar_summary_page",
+       new=mocked_html_summary_get_no_results)
+@patch("uqcsbot.scripts.events.get_calendar_file", new=mocked_events_ics)
+@patch("uqcsbot.scripts.events.get_current_time", new=mocked_get_august_2018_time)
+def test_events_recurring(uqcsbot: MockUQCSBot):
+    """
+    This test simulates the user invoking '!events', to test for recurring events.
+    """
+    uqcsbot.post_message(TEST_CHANNEL_ID, "!events")
+    messages = uqcsbot.test_messages.get(TEST_CHANNEL_ID, [])
+    assert len(messages) == 2
+    expected = "*`[Recurring] deadbeef Binary Exploitation Bootcamp`*\\n" \\
+        "*TUE AUG 7 18:30 - 20:00* _(78-346)_"
+    assert messages[1].get('attachments')[2].get('blocks')[0].get('text').get('text') == expected
diff --git a/uqcsbot/scripts/events.py b/uqcsbot/scripts/events.py
index 75b3659..3607b6a 100644
--- a/uqcsbot/scripts/events.py
+++ b/uqcsbot/scripts/events.py
@@ -8,6 +8,7 @@ from icalendar import Calendar
 from slackblocks import Attachment, SectionBlock
 from pytz import timezone, utc
 from typing import Tuple, Optional
+from dateutil.rrule import rrulestr
 
 from uqcsbot import bot, Command
 from uqcsbot.utils.command_utils import UsageSyntaxException, loading_status
@@ -22,6 +23,8 @@ FILTER_REGEX = re.compile('full|all|[0-9]+( weeks?)?|jan.*|feb.*|mar.*'
 BRISBANE_TZ = timezone('Australia/Brisbane')
 MONTH_NUMBER = {month.lower(): index for index, month in enumerate(month_abbr)}
 
+MAX_RECURRING_EVENTS = 3
+
 
 class EventFilter(object):
     def __init__(self, full=False, weeks=None, cap=None, month=None, is_valid=True):
@@ -80,11 +83,13 @@ class EventFilter(object):
 
 class Event(object):
     def __init__(self, start: datetime, end: datetime,
-                 location: str, summary: str, link: Optional[str], source: Optional[str] = None):
+                 location: str, summary: str, recurring: bool,
+                 link: Optional[str], source: Optional[str] = None):
         self.start = start
         self.end = end
         self.location = location
         self.summary = summary
+        self.recurring = recurring
         self.link = link
         self.source = source
 
@@ -104,19 +109,33 @@ class Event(object):
         return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
 
     @classmethod
-    def from_cal_event(cls, cal_event, source: str = "UQCS"):
-        start = cal_event.get('dtstart').dt
-        end = cal_event.get('dtend').dt
-        # ical 'dt' properties are parsed as a 'DDD' (datetime, date, duration) type.
-        # The below code converts a date to a datetime, where time is set to midnight.
-        if isinstance(start, date) and not isinstance(start, datetime):
-            start = datetime.combine(start, datetime.min.time()).astimezone(utc)
-        if isinstance(end, date) and not isinstance(end, datetime):
-            end = datetime.combine(end, datetime.max.time()).astimezone(utc)
+    def from_cal_event(cls, cal_event, source: str = "UQCS", recurrence_dt: datetime = None):
+        """
+        Converts an ical event to an Event
+
+        :param cal_event: event to convert
+        :param source: the calendar the event was sourced from
+        :param recurrence_dt: if this is a one off event then None
+                                else the date of this instance of a recurring event
+        """
+        if recurrence_dt:
+            start = recurrence_dt
+            end = recurrence_dt + (cal_event.get('DTEND').dt - cal_event.get('DTSTART').dt)
+
+        else:
+            start = cal_event.get('dtstart').dt
+            end = cal_event.get('dtend').dt
+            # ical 'dt' properties are parsed as a 'DDD' (datetime, date, duration) type.
+            # The below code converts a date to a datetime, where time is set to midnight.
+            if isinstance(start, date) and not isinstance(start, datetime):
+                start = datetime.combine(start, datetime.min.time()).astimezone(utc)
+            if isinstance(end, date) and not isinstance(end, datetime):
+                end = datetime.combine(end, datetime.max.time()).astimezone(utc)
         location = cal_event.get('location', 'TBA')
         summary = cal_event.get('summary')
         return cls(start, end, location,
-                   f"{'[External] ' if source == 'external' else ''}{summary}", None, source)
+                   f"{'[External] ' if source == 'external' else ''}{summary}",
+                   recurrence_dt is not None, None, source)
 
     @classmethod
     def from_seminar(cls, seminar_event: Tuple[str, str, datetime, str]):
@@ -124,7 +143,7 @@ class Event(object):
         # ITEE doesn't specify the length of seminars, but they are normally one hour
         end = start + timedelta(hours=1)
         # Note: this
-        return cls(start, end, location, f"[ITEE Seminar] {title}", link, "ITEE")
+        return cls(start, end, location, f"[ITEE Seminar] {title}", False, link, "ITEE")
 
     def __str__(self):
         d1 = self.start.astimezone(BRISBANE_TZ)
@@ -140,7 +159,7 @@ class Event(object):
 
         # Encode user-provided text to prevent certain characters
         # being interpreted as slack commands.
-        summary_str = Event.encode_text(self.summary)
+        summary_str = Event.encode_text(("[Recurring] " if self.recurring else "") + self.summary)
         location_str = Event.encode_text(self.location)
 
         if self.link is None:
@@ -155,12 +174,49 @@ class Event(object):
 
 def get_current_time():
     """
-    returns the current date and time
-    this function exists purely so it can be mocked for testing
+    Returns the current date and time
+    This function exists purely so it can be mocked for testing
     """
     return datetime.now(tz=BRISBANE_TZ).astimezone(utc)
 
 
+def handle_calendar(calendar) -> List[Event]:
+    """
+    Returns a list of events from a calendar
+    """
+    events = []
+    current_time = get_current_time()
+    # subcomponents are how icalendar returns the list of things in the calendar
+    for component in calendar.subcomponents:
+        # we are only interested in ones with the name VEVENT as they
+        # are events
+        if component.name != 'VEVENT':
+            continue
+        elif component.get('RRULE') is not None:
+            # If the until date exists, update it to UTC
+            if component['RRULE'].get('UNTIL') is not None:
+                until = datetime.combine(component['RRULE']['UNTIL'][0], datetime.min.time()) \\
+                            .astimezone(utc)
+                component['RRULE']['UNTIL'] = [until]
+            rule = rrulestr('\\n'.join([
+                    line for line in component.content_lines()
+                    if line.startswith('RRULE')
+                    or line.startswith('EXDATE')
+                ]), dtstart=component.get('DTSTART').dt)
+            rule = [dt for dt in list(rule) if dt > current_time]
+            for dt in rule[:MAX_RECURRING_EVENTS]:
+                dt = dt.replace(tzinfo=BRISBANE_TZ)
+                event = Event.from_cal_event(component, recurrence_dt=dt)
+                events.append(event)
+        else:
+            # we convert it to our own event class
+            event = Event.from_cal_event(component)
+            # then we want to filter out any events that are not after the current time
+            if event.start > current_time:
+                events.append(event)
+
+    return events
+
 @bot.on_command('events')
 @loading_status
 def handle_events(command: Command):
@@ -172,6 +228,7 @@ def handle_events(command: Command):
     """
 
     argument = command.arg if command.has_arg() else ""
+    current_time = get_current_time()
 
     source_get = {"uqcs": False, "itee": False, "external": False}
     for k in source_get:
@@ -186,37 +243,14 @@ def handle_events(command: Command):
     if not event_filter.is_valid:
         raise UsageSyntaxException()
 
-    uqcs_calendar = Calendar.from_ical(get_calendar_file("uqcs"))
-    current_time = get_current_time()
-
     events = []
-    # subcomponents are how icalendar returns the list of things in the calendar
-    if source_get["uqcs"]:
-        for c in uqcs_calendar.subcomponents:
-            # TODO: support recurring events
-            # we are only interested in ones with the name VEVENT as they
-            # are events we also currently filter out recurring events
-            if c.name != 'VEVENT' or c.get('RRULE') is not None:
-                continue
-
-            # we convert it to our own event class
-            event = Event.from_cal_event(c)
-            # then we want to filter out any events that are not after the current time
-            if event.start > current_time:
-                events.append(event)
 
+    if source_get["uqcs"]:
+        uqcs_calendar = Calendar.from_ical(get_calendar_file("uqcs"))
+        events += handle_calendar(uqcs_calendar)
     if source_get["external"]:
         external_calendar = Calendar.from_ical(get_calendar_file("external"))
-        for c in external_calendar.subcomponents:
-            if c.name != 'VEVENT' or c.get('RRULE') is not None:
-                continue
-
-            # we convert it to our own event class
-            event = Event.from_cal_event(c, "external")
-            # then we want to filter out any events that are not after the current time
-            if event.start > current_time:
-                events.append(event)
-
+        events += handle_calendar(external_calendar)
     if source_get["itee"]:
         try:
             # Try to include events from the ITEE seminars page