90ddef2d8c
Change-Id: I072e0018f24ced12b0b4557c8cc2db6b6a8d0ff5
313 lines
12 KiB
Python
313 lines
12 KiB
Python
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
import datetime
|
|
from io import StringIO
|
|
import os
|
|
import os.path
|
|
import pytz
|
|
import subprocess
|
|
import yaml
|
|
|
|
|
|
from yaml2ical.recurrence import supported_recurrences
|
|
|
|
DATES = {
|
|
'Monday': datetime.date(1900, 1, 1),
|
|
'Tuesday': datetime.date(1900, 1, 2),
|
|
'Wednesday': datetime.date(1900, 1, 3),
|
|
'Thursday': datetime.date(1900, 1, 4),
|
|
'Friday': datetime.date(1900, 1, 5),
|
|
'Saturday': datetime.date(1900, 1, 6),
|
|
'Sunday': datetime.date(1900, 1, 7),
|
|
}
|
|
ONE_WEEK = datetime.timedelta(weeks=1)
|
|
|
|
|
|
class SkipDate(object):
|
|
"""A date, time and reason to skip a meeting."""
|
|
|
|
def __init__(self, date, time, reason):
|
|
date = datetime.datetime.combine(date, time).replace(tzinfo=pytz.utc)
|
|
self.date = date
|
|
self.reason = reason
|
|
|
|
@property
|
|
def date_str(self):
|
|
return self.date.strftime("%Y%m%dT%H%M%SZ")
|
|
|
|
|
|
class Schedule(object):
|
|
"""A meeting schedule."""
|
|
|
|
def __init__(self, meeting, sched_yaml):
|
|
"""Initialize schedule from yaml."""
|
|
|
|
self.project = meeting.project
|
|
self.filefrom = meeting.filefrom
|
|
# mandatory: time, day, irc, freq, recurrence
|
|
try:
|
|
self.utc = sched_yaml['time']
|
|
self.time = datetime.datetime.strptime(sched_yaml['time'], '%H%M')
|
|
# Sanitize the Day
|
|
self.day = sched_yaml['day'].lower().capitalize()
|
|
self.irc = sched_yaml['irc']
|
|
self.freq = sched_yaml['frequency']
|
|
self._recurrence = sched_yaml['frequency']
|
|
except KeyError as e:
|
|
print("Invalid YAML meeting schedule definition - missing "
|
|
"attribute '{0}'".format(e.args[0]))
|
|
raise
|
|
|
|
# Validate inputs
|
|
try:
|
|
self.recurrence = supported_recurrences[self._recurrence]
|
|
except KeyError as e:
|
|
print("Invalid meeting recurrence '{0}' - "
|
|
"valid types: {1}".format(e.args[0],
|
|
supported_recurrences.keys()))
|
|
raise
|
|
|
|
# optional: start_date defaults to the current date if not present
|
|
if 'start_date' in sched_yaml:
|
|
try:
|
|
self.start_date = datetime.datetime.strptime(
|
|
str(sched_yaml['start_date']), '%Y%m%d')
|
|
except ValueError:
|
|
raise ValueError("Could not parse 'start_date' (%s) in %s" %
|
|
(sched_yaml['start_date'], self.filefrom))
|
|
else:
|
|
self.start_date = datetime.datetime.utcnow()
|
|
|
|
# optional: duration
|
|
if 'duration' in sched_yaml:
|
|
try:
|
|
self.duration = int(sched_yaml['duration'])
|
|
except ValueError:
|
|
raise ValueError("Could not parse 'duration' (%s) in %s" %
|
|
(sched_yaml['duration'], self.filefrom))
|
|
else:
|
|
self.duration = 60
|
|
|
|
if self.day not in DATES.keys():
|
|
raise ValueError("'%s' is not a valid day of the week")
|
|
|
|
# optional: skip_dates
|
|
# This is a sequence of mappings (YAML)
|
|
# This is a list of dicts (python)
|
|
if 'skip_dates' in sched_yaml:
|
|
self.skip_dates = []
|
|
for skip_date in sched_yaml['skip_dates']:
|
|
missing_keys = set(['skip_date', 'reason']) - set(skip_date)
|
|
if missing_keys:
|
|
raise KeyError(("Processing: %s Missing keys - %s" %
|
|
(self.filefrom, ','.join(missing_keys))))
|
|
|
|
# NOTE(tonyb) We need to include the time in an exdate
|
|
# without it the excluded occurrence never matches a
|
|
# scheduled occurrence.
|
|
try:
|
|
date_str = str(skip_date['skip_date'])
|
|
date = datetime.datetime.strptime(date_str, '%Y%m%d')
|
|
self.skip_dates.append(SkipDate(date, self.time.time(),
|
|
skip_date['reason']))
|
|
except ValueError:
|
|
raise ValueError(("Processing: %s Could not parse "
|
|
"skip_date - %s" %
|
|
(self.filefrom, skip_date['skip_date'])))
|
|
|
|
# NOTE(tonyb): We need to do this datetime shenanigans is so we can
|
|
# deal with meetings that start on day1 and end on day2.
|
|
self.meeting_start = datetime.datetime.combine(DATES[self.day],
|
|
self.time.time())
|
|
self.meeting_end = (self.meeting_start +
|
|
datetime.timedelta(minutes=self.duration))
|
|
if self.day == 'Sunday' and self.meeting_end.strftime("%a") == 'Mon':
|
|
self.meeting_start = self.meeting_start - ONE_WEEK
|
|
self.meeting_end = self.meeting_end - ONE_WEEK
|
|
|
|
def conflicts(self, other):
|
|
"""Checks for conflicting schedules."""
|
|
|
|
def _non_weekly_conflict_detection(self, other):
|
|
week = {
|
|
'weekly': set([0, 1, 2, 3]),
|
|
'biweekly-even': set([0, 2]),
|
|
'biweekly-odd': set([1, 3]),
|
|
'quadweekly': set([0]),
|
|
'quadweekly-week-1': set([1]),
|
|
'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]),
|
|
'second-monday': set([0, 1, 2, 3]),
|
|
'second-tuesday': set([0, 1, 2, 3]),
|
|
'second-wednesday': set([0, 1, 2, 3]),
|
|
'second-thursday': set([0, 1, 2, 3]),
|
|
'second-friday': set([0, 1, 2, 3]),
|
|
'third-monday': set([0, 1, 2, 3]),
|
|
'third-tuesday': set([0, 1, 2, 3]),
|
|
'third-wednesday': set([0, 1, 2, 3]),
|
|
'third-thursday': set([0, 1, 2, 3]),
|
|
'third-friday': set([0, 1, 2, 3]),
|
|
'fourth-monday': set([0, 1, 2, 3]),
|
|
'fourth-tuesday': set([0, 1, 2, 3]),
|
|
'fourth-wednesday': set([0, 1, 2, 3]),
|
|
'fourth-thursday': set([0, 1, 2, 3]),
|
|
'fourth-friday': set([0, 1, 2, 3]),
|
|
}
|
|
|
|
return len(week[self.freq].intersection(week[other.freq])) > 0
|
|
|
|
# NOTE(tonyb): .meeting_start also includes the day of the week. So no
|
|
# need to check .day explictly
|
|
return ((self.irc == other.irc) and
|
|
((self.meeting_start < other.meeting_end) and
|
|
(other.meeting_start < self.meeting_end)) and
|
|
_non_weekly_conflict_detection(self, other))
|
|
|
|
|
|
class Meeting(object):
|
|
"""An online meeting."""
|
|
|
|
def __init__(self, data):
|
|
"""Initialize meeting from meeting yaml description."""
|
|
|
|
yaml_obj = yaml.safe_load(data)
|
|
|
|
try:
|
|
self.chair = yaml_obj['chair']
|
|
self.description = yaml_obj['description']
|
|
self.project = yaml_obj['project']
|
|
except KeyError as e:
|
|
print("Invalid YAML meeting definition - missing "
|
|
"attribute '{0}'".format(e.args[0]))
|
|
raise
|
|
|
|
# Find any extra values the user has provided that they might
|
|
# want to have access to in their templates.
|
|
self.extras = {}
|
|
self.extras.update(yaml_obj)
|
|
for k in ['chair', 'description', 'project', 'schedule']:
|
|
if k in self.extras:
|
|
del self.extras[k]
|
|
|
|
try:
|
|
self.filefrom = os.path.basename(data.name)
|
|
self.outfile = os.path.splitext(self.filefrom)[0] + '.ics'
|
|
except AttributeError:
|
|
self.filefrom = "stdin"
|
|
self.outfile = "stdin.ics"
|
|
|
|
self.schedules = []
|
|
for sch in yaml_obj['schedule']:
|
|
s = Schedule(self, sch)
|
|
self.schedules.append(s)
|
|
|
|
# Set a default last update time
|
|
self.last_update = datetime.datetime.utcnow()
|
|
|
|
@classmethod
|
|
def fromfile(cls, yaml_file):
|
|
with open(yaml_file, 'r') as f:
|
|
meeting = cls(f)
|
|
|
|
meeting.last_update = _get_update_time(yaml_file)
|
|
return meeting
|
|
|
|
@classmethod
|
|
def fromstring(cls, yaml_string):
|
|
s = StringIO(yaml_string)
|
|
return cls(s)
|
|
|
|
|
|
def _get_update_time(src_file):
|
|
"""Attempts to figure out the last time the file was updated.
|
|
|
|
If the file is under source control, this will try to find the last time it
|
|
was updated. If that is not possible, it will fallback to using the last
|
|
time the local file was modified.
|
|
"""
|
|
try:
|
|
last_updated = subprocess.check_output(
|
|
[
|
|
'git', 'log', '-n1', '--format=%ad',
|
|
'--date=format:%Y-%m-%d %H:%M:%S',
|
|
'--', src_file,
|
|
]
|
|
).decode('utf-8').strip()
|
|
return datetime.datetime.strptime(last_updated, '%Y-%m-%d %H:%M:%S')
|
|
except (subprocess.CalledProcessError, ValueError):
|
|
return datetime.datetime.fromtimestamp(os.path.getmtime(src_file))
|
|
|
|
|
|
def load_meetings(yaml_source):
|
|
"""Build YAML object and load meeting data
|
|
|
|
:param yaml_source: source data to load, which can be a directory or
|
|
stream.
|
|
:returns: list of meeting objects
|
|
"""
|
|
meetings = []
|
|
# Determine what the yaml_source is. Files must have .yaml extension
|
|
# to be considered valid.
|
|
if os.path.isdir(yaml_source):
|
|
for root, dirs, files in os.walk(yaml_source):
|
|
for f in files:
|
|
# Build the entire file path and append to the list of yaml
|
|
# meetings
|
|
if os.path.splitext(f)[1] == '.yaml':
|
|
yaml_file = os.path.join(root, f)
|
|
meetings.append(Meeting.fromfile(yaml_file))
|
|
elif (os.path.isfile(yaml_source) and
|
|
os.path.splitext(yaml_source)[1] == '.yaml'):
|
|
meetings.append(Meeting.fromfile(yaml_source))
|
|
elif isinstance(yaml_source, str):
|
|
return [Meeting.fromstring(yaml_source)]
|
|
|
|
if not meetings:
|
|
# If we don't have a .yaml file, a directory of .yaml files, or any
|
|
# YAML data fail out here.
|
|
raise ValueError("No .yaml file, directory containing .yaml files, "
|
|
"or YAML data found.")
|
|
else:
|
|
meetings.sort(key=lambda x: x.project)
|
|
return meetings
|
|
|
|
|
|
class MeetingConflictError(Exception):
|
|
pass
|
|
|
|
|
|
def check_for_meeting_conflicts(meetings):
|
|
"""Check if a list of meetings have conflicts.
|
|
|
|
:param meetings: list of Meeting objects
|
|
|
|
"""
|
|
|
|
for i in range(len(meetings)):
|
|
schedules = meetings[i].schedules
|
|
for j in range(i + 1, len(meetings)):
|
|
other_schedules = meetings[j].schedules
|
|
for schedule in schedules:
|
|
for other_schedule in other_schedules:
|
|
if schedule.conflicts(other_schedule):
|
|
msg_dict = {'one': schedule.filefrom,
|
|
'two': other_schedule.filefrom}
|
|
raise MeetingConflictError(
|
|
"Conflict between %(one)s and %(two)s" % msg_dict)
|