diff --git a/README.rst b/README.rst index 0358ebd..76cac58 100644 --- a/README.rst +++ b/README.rst @@ -163,6 +163,14 @@ yaml2ical supports a number of possible frequency options: * ``quadweekly-week-2``, ``quadweekly-alternate``: Occurs when ``ISOweek % 4 == 2`` * ``quadweekly-week-3``: Occurs when ``ISOweek % 4 == 3`` +* Event occurs in the first week of a month: + + * ``first-monday``: On the first Monday of the month. + * ``first-tuesday``: On the first Tuesday of the month. + * ``first-wednesday``: On the first Wednesday of the month. + * ``first-thursday``: On the first Thursday of the month. + * ``first-friday``: On the first Friday of the month. + * Event doesn't happen on a defined schedule but is used as a placeholder for html generation: diff --git a/yaml2ical/meeting.py b/yaml2ical/meeting.py index a2484f9..ee54840 100644 --- a/yaml2ical/meeting.py +++ b/yaml2ical/meeting.py @@ -147,6 +147,11 @@ class Schedule(object): 'quadweekly-week-2': set([2]), 'quadweekly-week-3': set([3]), 'quadweekly-alternate': set([2]), + 'first-monday': set([0, 1, 2, 3]), + 'first-tuesday': set([0, 1, 2, 3]), + 'first-wednesday': set([0, 1, 2, 3]), + 'first-thursday': set([0, 1, 2, 3]), + 'first-friday': set([0, 1, 2, 3]), } return len(week[self.freq].intersection(week[other.freq])) > 0 diff --git a/yaml2ical/recurrence.py b/yaml2ical/recurrence.py index 11687fe..1d18405 100644 --- a/yaml2ical/recurrence.py +++ b/yaml2ical/recurrence.py @@ -10,6 +10,7 @@ # License for the specific language governing permissions and limitations # under the License. +import calendar import datetime @@ -131,6 +132,66 @@ class AdhocRecurrence(object): def __str__(self): return "Occurs as needed, no fixed schedule." + +class MonthlyRecurrence(object): + """Meetings occuring every month.""" + def __init__(self, week, day): + self._week = week + self._day = day + + def next_occurence(self, current_date_time, day): + """Return the date of the next meeting. + + :param current_date_time: datetime object of meeting + :param day: weekday the meeting is held on + + :returns: datetime object of the next meeting time + """ + weekday = WEEKDAYS[day] + + month = current_date_time.month + 1 + year = current_date_time.year + if current_date_time.month == 12: + month = 1 + year = year + 1 + next_month_dates = calendar.monthcalendar(year, month) + + # We can't simply index into the dates for the next month + # because we don't know that the first week is full of days + # that actually appear in that month. Therefore we loop + # through them counting down until we've skipped enough weeks. + skip_weeks = self._week - 1 + for week in next_month_dates: + day = week[weekday] + # Dates in the week that fall in other months + # are 0 so we want to skip counting those weeks. + if not day: + continue + # If we have skipped all of the weeks we need to, + # we have the day. + if not skip_weeks: + return datetime.datetime( + year, month, day, + current_date_time.hour, current_date_time.minute, + current_date_time.second, current_date_time.microsecond, + ) + skip_weeks -= 1 + + raise ValueError( + 'Could not compute week {} of next month for {}'.format( + self._week, current_date_time) + ) + + def rrule(self): + return { + 'freq': 'monthly', + 'byday': '{}{}'.format(self._week, self._day[:2].upper()), + } + + def __str__(self): + return "Monthly" + + supported_recurrences = { 'weekly': WeeklyRecurrence(), 'biweekly-odd': BiWeeklyRecurrence(style='odd'), @@ -141,4 +202,9 @@ supported_recurrences = { 'quadweekly-week-3': QuadWeeklyRecurrence(week=3), 'quadweekly-alternate': QuadWeeklyRecurrence(week=2), 'adhoc': AdhocRecurrence(), + 'first-monday': MonthlyRecurrence(week=1, day='Monday'), + 'first-tuesday': MonthlyRecurrence(week=1, day='Tuesday'), + 'first-wednesday': MonthlyRecurrence(week=1, day='Wednesday'), + 'first-thursday': MonthlyRecurrence(week=1, day='Thursday'), + 'first-friday': MonthlyRecurrence(week=1, day='Friday'), } diff --git a/yaml2ical/tests/sample_data.py b/yaml2ical/tests/sample_data.py index bf221df..dd1996e 100644 --- a/yaml2ical/tests/sample_data.py +++ b/yaml2ical/tests/sample_data.py @@ -362,3 +362,87 @@ chair: John Doe description: > Example Quadweekly Alternate meeting """ + +FIRST_MONDAY_MEETING = """ +project: OpenStack Random Meeting +agenda_url: http://agenda.com/ +project_url: http://project.com +schedule: + - time: '2200' + day: Monday + irc: openstack-meeting + frequency: first-monday +chair: John Doe +description: > + Example Monthly meeting +""" + +FIRST_TUESDAY_MEETING = """ +project: OpenStack Random Meeting +agenda_url: http://agenda.com/ +project_url: http://project.com +schedule: + - time: '2200' + day: Tuesday + irc: openstack-meeting + frequency: first-tuesday +chair: John Doe +description: > + Example Monthly meeting +""" + +WEEKLY_MEETING_2200 = """ +project: OpenStack Subteam Meeting +schedule: + - time: '2200' + day: Wednesday + irc: openstack-meeting + frequency: weekly +chair: Joe Developer +description: > + Weekly meeting for Subteam project. +agenda: | + * Top bugs this week +""" + +FIRST_WEDNESDAY_MEETING = """ +project: OpenStack Random Meeting +agenda_url: http://agenda.com/ +project_url: http://project.com +schedule: + - time: '2200' + day: Wednesday + irc: openstack-meeting + frequency: first-wednesday +chair: John Doe +description: > + Example Monthly meeting +""" + +FIRST_THURSDAY_MEETING = """ +project: OpenStack Random Meeting +agenda_url: http://agenda.com/ +project_url: http://project.com +schedule: + - time: '2200' + day: Thursday + irc: openstack-meeting + frequency: first-thursday +chair: John Doe +description: > + Example Monthly meeting +""" + +FIRST_FRIDAY_MEETING = """ +project: OpenStack Random Meeting +agenda_url: http://agenda.com/ +project_url: http://project.com +schedule: + - time: '2200' + day: Friday + irc: openstack-meeting + frequency: first-friday +chair: John Doe +description: > + Example Monthly meeting +""" diff --git a/yaml2ical/tests/test_meeting.py b/yaml2ical/tests/test_meeting.py index 03701e4..689eec9 100644 --- a/yaml2ical/tests/test_meeting.py +++ b/yaml2ical/tests/test_meeting.py @@ -163,6 +163,20 @@ class MeetingTestCase(unittest.TestCase): sample_data.CONFLICTING_WEEKLY_MEETING, sample_data.MEETING_WITH_DURATION) + def test_monthly_conflicts(self): + self.should_be_conflicting( + sample_data.WEEKLY_MEETING_2200, + sample_data.FIRST_WEDNESDAY_MEETING) + self.should_be_conflicting( + sample_data.BIWEEKLY_EVEN_MEETING, + sample_data.FIRST_WEDNESDAY_MEETING) + self.should_be_conflicting( + sample_data.QUADWEEKLY_MEETING, + sample_data.FIRST_WEDNESDAY_MEETING) + self.should_be_conflicting( + sample_data.ALTERNATING_MEETING, + sample_data.FIRST_WEDNESDAY_MEETING) + def test_skip_meeting(self): meeting_yaml = sample_data.MEETING_WITH_SKIP_DATES # Copied from sample_data.MEETING_WITH_SKIP_DATES diff --git a/yaml2ical/tests/test_recurrence.py b/yaml2ical/tests/test_recurrence.py index 5a05004..11413f3 100644 --- a/yaml2ical/tests/test_recurrence.py +++ b/yaml2ical/tests/test_recurrence.py @@ -83,3 +83,25 @@ class RecurrenceTestCase(unittest.TestCase): self.assertEqual( 'Every four weeks on week %d of the four week rotation' % i, str(recurrence.QuadWeeklyRecurrence(week=i))) + + def test_monthly_first_week(self): + rec = recurrence.MonthlyRecurrence(week=1, day='Wednesday') + self.assertEqual( + datetime.datetime(2014, 11, 5, 2, 47, 28, 832666), + self.next_meeting(rec), + ) + + def test_monthly_second_week(self): + rec = recurrence.MonthlyRecurrence(week=2, day='Wednesday') + self.assertEqual( + datetime.datetime(2014, 11, 12, 2, 47, 28, 832666), + self.next_meeting(rec), + ) + + def test_monthly_invalid_week(self): + rec = recurrence.MonthlyRecurrence(week=6, day='Wednesday') + self.assertRaises( + ValueError, + self.next_meeting, + rec, + )