def test_new_timezone_wt_shift_forward(self): new_wt = Availability.new_timezone_wt(c.sunday_0000, c.et_ds, 'UTC') self.assertEqual(new_wt, WeeklyTime(0, 4, 0)) new_wt = Availability.new_timezone_wt(c.tuesday_1715, c.et_no_ds, 'Asia/Tokyo') self.assertEqual(new_wt, WeeklyTime(3, 7, 15)) new_wt = Availability.new_timezone_wt(c.saturday_2345, c.kathmandu_2017_end, 'Australia/West') self.assertEqual(new_wt, WeeklyTime(0, 2, 0))
def test_new_timezone_wt_value_error(self): with self.assertRaises(ValueError): Availability.new_timezone_wt(WeeklyTime(0, 0, 14), c.utc_halloween, 'UTC') with self.assertRaises(ValueError): Availability.new_timezone_wt(WeeklyTime(0, 0, 0), c.dt_2000_1_1, 'UTC') with self.assertRaises(ValueError): Availability.new_timezone_wt(WeeklyTime(0, 0, 0), c.utc_halloween, 'utc')
def test_new_timezone_wt_shift_backward(self): new_wt = Availability.new_timezone_wt(c.sunday_0000, c.et_ds, 'US/Arizona') self.assertEqual(new_wt, WeeklyTime(6, 21, 0)) new_wt = Availability.new_timezone_wt(c.tuesday_1715, c.kabul_2000_1_1, 'US/Samoa') self.assertEqual(new_wt, WeeklyTime(2, 1, 45)) new_wt = Availability.new_timezone_wt(c.thursday_0630, c.chatham_ds, 'Pacific/Midway') self.assertEqual(new_wt, WeeklyTime(3, 5, 45))
def test_get_availability_matches_two_matches(self): student = c.new_user(c.student, {'availability': c.free_first_seven_avail}) tutor = c.new_user(c.tutor, {'availability': c.always_free_avail}) matches = student.get_availability_matches(tutor, 1) correct_matches = [ Match(student, tutor, WeeklyTime(0, 5, 0), datetime(2018, 1, 1, 5), 1), Match(student, tutor, WeeklyTime(0, 5, 15), datetime(2018, 1, 1, 5), 1) ] self.assertEqual(matches, correct_matches)
def test_constants(self): self.assertEqual(Availability.MINUTES_PER_SLOT, 15) self.assertEqual(Availability.MINUTES_PER_COURSE, 90) self.assertEqual(Availability.SLOTS_PER_WEEK, c.SLOTS_PER_WEEK) self.assertEqual(Availability.SLOT_START_TIMES[0], WeeklyTime(0, 0, 0)) self.assertEqual(Availability.SLOT_START_TIMES[-1], WeeklyTime(6, 23, 45)) self.assertEqual( Availability.SLOT_START_TIME_TO_INDEX[WeeklyTime(0, 0, 0)], 0) self.assertEqual( Availability.SLOT_START_TIME_TO_INDEX[WeeklyTime(6, 23, 45)], c.SLOTS_PER_WEEK - 1)
def test_shared_course_start_times_UTC_kabul_et_no_ds_overlap_greater_than_one( self): user1 = c.new_user( c.student, { 'tz_str': 'Asia/Kabul', 'availability': c.always_free_avail, 'earliest_start_date': c.dt_2000_1_1 }) user2 = c.new_user( c.student, { 'tz_str': 'US/Eastern', 'availability': c.free_first_seven_avail, 'earliest_start_date': date(2017, 3, 12) }) shared = user1.shared_course_start_times_UTC(user2) self.assertEqual(shared, [WeeklyTime(0, 5, 0), WeeklyTime(0, 5, 15)])
def test_init_attributes(self): self.assertEqual(c.match_two_weeks.student, c.student) self.assertEqual(c.match_two_weeks.tutor, c.tutor) self.assertEqual(c.match_two_weeks.shared_courses, ['Math']) self.assertEqual(c.match_two_weeks.course_start_wt_UTC, WeeklyTime(0, 5, 0)) self.assertEqual(c.match_two_weeks.earliest_course_start_UTC, datetime(2018, 1, 1, 5, 0)) self.assertEqual(c.match_two_weeks.weeks_per_course, 2) student_course_schedule = [ c.et.localize(datetime(2018, 1, 7, 0, 0)), c.et.localize(datetime(2018, 1, 14, 0, 0)) ] self.assertEqual(c.match_two_weeks.student_course_schedule, student_course_schedule) tutor_course_schedule = [ c.kabul.localize(datetime(2018, 1, 7, 9, 30)), c.kabul.localize(datetime(2018, 1, 14, 9, 30)) ] self.assertEqual(c.match_two_weeks.tutor_course_schedule, tutor_course_schedule) UTC_course_schedule = [ c.utc.localize(datetime(2018, 1, 7, 5, 0)), c.utc.localize(datetime(2018, 1, 14, 5, 0)) ] self.assertEqual(c.match_two_weeks.UTC_course_schedule, UTC_course_schedule)
def test_get_availability_matches_one_match_gender_compatible_shared_courses( self): matches = c.student.get_availability_matches(c.tutor, 1) correct_matches = [ Match(c.student, c.tutor, WeeklyTime(0, 5, 0), datetime(2018, 1, 1, 5), 1) ] self.assertEqual(matches, correct_matches)
def test_get_availability_matches_one_match_gender_incompatible_no_shared_courses( self): student = c.new_user(c.student, { 'gender_preference': 'FEMALE', 'courses': ['English'] }) matches = student.get_availability_matches(c.tutor, 1) correct_matches = [ Match(student, c.tutor, WeeklyTime(0, 5, 0), datetime(2018, 1, 1, 5), 1) ] self.assertEqual(matches, correct_matches)
def daylight_saving_valid(self): """Determines whether or not the match is valid during all weeks of the schedule. Even though the match will definitely be valid on earliest_course_start_UTC, it is possible that the student or tutor will no longer be able to make the course if daylight saving occurs for the student or for the tutor as the course progresses. Also, some of the scheduled datetimes may be non-existent or ambiguous because of daylight saving. Returns: A boolean whether or not both the student and the tutor can make all courses in their respective schedules. """ # Check that the student schedule is valid for student_dt in self.student_course_schedule: student_wt = WeeklyTime.from_datetime(student_dt) index = Availability.SLOT_START_TIME_TO_INDEX[student_wt] if not self.student.availability.free_course_slots[index]: return False # Check that all minutes of the class are valid naive_student_dt = student_dt.replace(tzinfo=None) for i in range(Availability.MINUTES_PER_COURSE): dt = naive_student_dt + timedelta(minutes=i) if not util.naive_dt_is_valid(dt, self.student.tz_str): return False # Check that the tutor schedule is valid for tutor_dt in self.tutor_course_schedule: tutor_wt = WeeklyTime.from_datetime(tutor_dt) index = Availability.SLOT_START_TIME_TO_INDEX[tutor_wt] if not self.tutor.availability.free_course_slots[index]: return False # Check that all minutes of the class are valid naive_tutor_dt = tutor_dt.replace(tzinfo=None) for i in range(Availability.MINUTES_PER_COURSE): dt = naive_tutor_dt + timedelta(minutes=i) if not util.naive_dt_is_valid(dt, self.tutor.tz_str): return False return True
def test_shared_course_start_times_UTC_kabul_et_no_ds_overlap_one(self): user1 = c.new_user( c.student, { 'tz_str': 'Asia/Kabul', 'availability': c.free_first_six_avail, 'earliest_start_date': c.dt_2000_1_1 }) avail = Availability.from_dict({'6': [['14:30', '16:00']]}) user2 = c.new_user( c.student, { 'tz_str': 'US/Eastern', 'availability': avail, 'earliest_start_date': date(2017, 3, 12) }) shared = user1.shared_course_start_times_UTC(user2) self.assertEqual(shared, [WeeklyTime(6, 19, 30)])
class Availability: """Represents a weekly availability.""" DAYS_PER_WEEK = 7 HOURS_PER_DAY = 24 MINUTES_PER_HOUR = 60 MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR MINUTES_PER_SLOT = 15 if MINUTES_PER_HOUR % MINUTES_PER_SLOT != 0: raise ValueError('MINUTES_PER_SLOT must be a divisor of 60') SLOTS_PER_HOUR = MINUTES_PER_HOUR // MINUTES_PER_SLOT SLOTS_PER_DAY = SLOTS_PER_HOUR * HOURS_PER_DAY SLOTS_PER_WEEK = MINUTES_PER_WEEK // MINUTES_PER_SLOT MINUTES_PER_COURSE = 90 SLOTS_PER_COURSE = int( math.ceil(MINUTES_PER_COURSE / float(MINUTES_PER_SLOT))) SLOT_START_TIMES = [] for day in range(DAYS_PER_WEEK): for hour in range(HOURS_PER_DAY): for k in range(SLOTS_PER_HOUR): SLOT_START_TIMES.append( WeeklyTime(day, hour, k * MINUTES_PER_SLOT)) SLOT_START_TIME_TO_INDEX = {} for i in range(SLOTS_PER_WEEK): SLOT_START_TIME_TO_INDEX[SLOT_START_TIMES[i]] = i def __init__(self, free_slots): """ Args: free_slots: A boolean array of length SLOTS_PER_WEEK such that free_slots[i] is whether or not the user is free for the slot starting at SLOT_START_TIMES[i]. """ if len(free_slots) != self.SLOTS_PER_WEEK: raise ValueError('free_slots must have length SLOTS_PER_WEEK') self.free_slots = free_slots # boolean array such that i-th entry is whether or not user is free to # start a course at SLOT_START_TIMES[i] self.free_course_slots = [] for i in range(self.SLOTS_PER_WEEK): is_free = all(self.free_slots[(i + j) % self.SLOTS_PER_WEEK] for j in range(self.SLOTS_PER_COURSE)) self.free_course_slots.append(is_free) def __str__(self): lines = [] for i in range(self.SLOTS_PER_WEEK): if self.free_slots[i]: lines.append( str(self.SLOT_START_TIMES[i]) + ' - ' + str(self.SLOT_START_TIMES[(i + 1) % self.SLOTS_PER_WEEK])) return '\n'.join(lines) def __eq__(self, other): return self.free_slots == other.free_slots def __ne__(self, other): return not self.__eq__(other) @classmethod def time_str_to_index(cls, time_str): """Converts time string of the form 'HH:MM' to the corresponding index of SLOT_START_TIMES. Args: time_str: A string of the form 'HH:MM' representing a time with a specified hour and minute. Hour must be between 0 and 24, inclusive. (24 is allowed because of intervals like ['20:00','24:00'] exist in availability dictionaries.) Minute must be between 0 and 59, inclusive. Also the number of minutes elapsed between 00:00 and the time must be a multiple of cls.MINUTES_PER_SLOT. Returns: An integer i such that cls.SLOT_START_TIMES[i] corresponds to Sunday at the time given by time_str. """ if not re.match(r'[0-9][0-9]:[0-9][0-9]', time_str): raise ValueError('time_str must be of the form "HH:MM"') hours = int(time_str.split(':')[0]) minutes = int(time_str.split(':')[1]) if hours not in list(range(cls.HOURS_PER_DAY + 1)): raise ValueError('The hour part of time_str must be in range(25)') if minutes not in list(range(cls.MINUTES_PER_HOUR)): raise ValueError( 'The minute part of time_str must be in range(60)') total_minutes = cls.MINUTES_PER_HOUR * hours + minutes if total_minutes % cls.MINUTES_PER_SLOT != 0: raise ValueError( 'The number of minutes elapsed between 00:00 and time_str must be a multiple of MINUTES_PER_SLOT' ) return total_minutes / cls.MINUTES_PER_SLOT @classmethod def parse_dict(cls, availability_dict): """ Extracts the free time slots from an availability dictionary. Args: availability_dict: A dict mapping a day of the week index expressed as a string to a list of lists of length two. Each internal list of length two is of the form [start_time, end_time], where start_time and end_time are the start and end times in the form 'HH:MM' of when the user is available, and start_time is not equal to end_time. Also the number of minutes elapsed between 00:00 and each of start_time and end_time must be a multiple of cls.MINUTES_PER_SLOT. ex. {'0': [['00:00', '02:30'], ['23:00', '24:00']], '4': [['17:30', '18:00']]} means that the user is free 12am-2:30am Sunday, 11:30pm Sunday to 12am Monday, and 5:30pm-6pm on Thursday. Returns: free_slots_indices: A set of indices i such that the user is free during the slot starting at cls.SLOT_START_TIMES[i] """ for day_index_str in availability_dict: if day_index_str not in list( map(str, list(range(cls.DAYS_PER_WEEK)))): raise ValueError( 'Each key in availability_dict must be a string form of an integer in range(7)' ) free_slots_indices = set([]) for day_str in availability_dict: intervals = availability_dict[day_str] day_slot_index = int(day_str) * cls.SLOTS_PER_DAY for interval in intervals: if len(interval) != 2: raise ValueError( 'time interval in availability_dict must have length 2' ) if interval[0] == interval[1]: raise ValueError( 'time interval in availability_dict must have different start time and end time' ) start_index = day_slot_index + cls.time_str_to_index( interval[0]) end_index = day_slot_index + cls.time_str_to_index(interval[1]) free_slots_indices.update(list(range(start_index, end_index))) return free_slots_indices @classmethod def from_dict(cls, availability_dict): """Instantiates an Availability object from an availability dictionary. Args: availability_dict: A dict mapping a day of the week index expressed as a string to a list of lists of length two. Each internal list of length two is of the form [start_time, end_time], where start_time and end_time are the start and end times in the form 'HH:MM' of when the user is available, and start_time is not equal to end_time. Also the number of minutes elapsed between 00:00 and each of start_time and end_time must be a multiple of cls.MINUTES_PER_SLOT. ex. {'0': [['00:00', '02:30'], ['23:00', '24:00']], '4': [['17:30', '18:00']]} means that the user is free 12am-2:30am Sunday, 11:30pm Sunday to 12am Monday, and 5:30pm-6pm on Thursday. Returns: An Availability object with free slots given by the intervals in availability_dict. """ for day_index_str in availability_dict: if day_index_str not in list( map(str, list(range(cls.DAYS_PER_WEEK)))): raise ValueError( 'Each key in availability_dict must be a string form of an integer in range(7)' ) free_slots_indices = cls.parse_dict(availability_dict) free_slots = [(i in free_slots_indices) for i in range(cls.SLOTS_PER_WEEK)] return cls(free_slots) @classmethod def UTC_offset_minutes(cls, aware_dt): """Converts a timezone-aware datetime to the number of minutes offset from UTC. Args: aware_dt: A timezone-aware datetime. Returns: offset_minutes: An integer representing the signed number of minutes that aware_dt is offset from UTC. """ if aware_dt.tzinfo is None or aware_dt.tzinfo.utcoffset( aware_dt) is None: raise ValueError('aware_dt must be a timezone-aware datetime') offset_str = aware_dt.strftime('%z') minutes = cls.MINUTES_PER_HOUR * int(offset_str[1:3]) + int( offset_str[3:5]) if offset_str[0] == '+': offset_minutes = minutes elif offset_str[0] == '-': offset_minutes = -minutes else: raise ValueError('offset_str must start with "+" or "-"') return offset_minutes @classmethod def new_timezone_wt(cls, wt, aware_dt, new_tz_str): """ Shifts a WeeklyTime to a new timezone. Args: wt: A WeeklyTime object in SLOT_START_TIMES. aware_dt: A timezone-aware datetime whose timezone is the current timezone of wt. Also used as the reference datetime for the timezone conversion. new_tz_str: A string representing the new timezone to shift to. Must be in the pytz timezone database. Returns: new_wt: A WeeklyTime object that represents wt after shifting it to the timezone new_tz_str. """ if wt not in cls.SLOT_START_TIMES: raise ValueError('wt must be in SLOT_START_TIMES') if aware_dt.tzinfo is None or aware_dt.tzinfo.utcoffset( aware_dt) is None: raise ValueError('aware_dt must be a timezone-aware datetime') if new_tz_str not in pytz.all_timezones_set: raise ValueError( 'new_tz_str must be in the pytz timezone database') new_tz = pytz.timezone(new_tz_str) new_dt = aware_dt.astimezone(new_tz) forward_shift_minutes = (cls.UTC_offset_minutes(new_dt) - cls.UTC_offset_minutes(aware_dt)) if forward_shift_minutes % cls.MINUTES_PER_SLOT != 0: raise ValueError( 'MINUTES_PER_SLOT must be a divisor of forward_shift_minutes') n_slots = forward_shift_minutes / cls.MINUTES_PER_SLOT index = cls.SLOT_START_TIME_TO_INDEX[wt] new_wt = cls.SLOT_START_TIMES[(index + n_slots) % cls.SLOTS_PER_WEEK] return new_wt def shared_course_start_times(self, other_availability): """Computes weekly times during which both Availability objects are free to start a course. Args: other_availability: An Availability object. Returns: A list of WeeklyTime objects during which self and other_availability are both free to start a course. """ return [ self.SLOT_START_TIMES[i] for i in range(self.SLOTS_PER_WEEK) if self.free_course_slots[i] and other_availability.free_course_slots[i] ] def forward_shifted(self, forward_shift_minutes): """ Returns a copy of self shifted forward in time. Args: forward_shift_minutes: An integer representing the number of minutes to shift self forward in time. This must be a multiple of self.MINUTES_PER_SLOT. Returns: shifted_availability: An Availability object representing self after shifting all time slots forward by forward_shift_minutes minutes. For example, if the user is free 1pm-2pm on Tuesday and forward_shift_minutes == 75, then the returned Availability object will be free 2:15pm-3:15pm on Tuesday. If the user is free 1pm-2pm on Tuesday and forward_shift_minutes == -60, then the returned Availability object will be free 12pm-1pm on Tuesday. """ if forward_shift_minutes % self.MINUTES_PER_SLOT != 0: raise ValueError( 'forward_shift_minutes must be a multiple of MINUTES_PER_SLOT') n_slots = forward_shift_minutes / self.MINUTES_PER_SLOT shifted_free_slots = [ self.free_slots[(i - n_slots) % self.SLOTS_PER_WEEK] for i in range(self.SLOTS_PER_WEEK) ] shifted_availability = Availability(shifted_free_slots) return shifted_availability def new_timezone(self, current_tz_str, new_tz_str, naive_dt_in_new_tz): """Returns a copy of self after shifting to a new timezone. Args: current_tz_str: A string representing the timezone of self. Must be in the pytz timezone database. new_tz_str: A string representing the new timezone to shift to. Must be in the pytz timezone database. naive_dt_in_new_tz: An naive datetime object that provides the reference time in the timezone new_tz_str with which to calculate UTC offsets. Must be a valid (neither non-existent nor ambiguous) in the timezone new_tz_str. Returns: An Availability object that represents self after shifting from the timezone current_tz_str to the timezone new_tz_str on the datetime naive_dt_in_new_tz in new_tz_str. """ if current_tz_str not in pytz.all_timezones_set: raise ValueError( 'current_tz_str must be in the pytz timezone database') if new_tz_str not in pytz.all_timezones_set: raise ValueError( 'new_tz_str must be in the pytz timezone database') if (naive_dt_in_new_tz.tzinfo is not None and naive_dt_in_new_tz.tzinfo.utcoffset(naive_dt_in_new_tz) is not None): raise ValueError('naive_dt_in_new_tz must be a naive datetime') if not util.naive_dt_is_valid(naive_dt_in_new_tz, new_tz_str): raise ValueError( 'naive_dt_in_new_tz must be a valid datetime in the timezone new_tz_str' ) current_tz = pytz.timezone(current_tz_str) new_tz = pytz.timezone(new_tz_str) dt_new_tz = new_tz.localize(naive_dt_in_new_tz) dt_current_tz = dt_new_tz.astimezone(current_tz) forward_shift_minutes = (self.UTC_offset_minutes(dt_new_tz) - self.UTC_offset_minutes(dt_current_tz)) return self.forward_shifted(forward_shift_minutes)
def test_shared_course_start_times_overlap_greater_than_one(self): times = c.always_free_avail.shared_course_start_times( c.free_first_seven_avail) self.assertEqual(times, [WeeklyTime(0, 0, 0), WeeklyTime(0, 0, 15)])
def test_sunday_0000_equals_sunday_0000(self): self.assertEqual(c.sunday_0000, WeeklyTime(0, 0, 0))
def test_shared_course_start_times_overlap_one(self): times = c.always_free_avail.shared_course_start_times( c.free_sat_sun_six_avail) self.assertEqual(times, [WeeklyTime(6, 23, 15)])
def test_from_datetime_2017_01_29_0059(self): self.assertEqual(WeeklyTime.from_datetime(c.dt_2017_01_29_0059), c.sunday_0059)
def test_from_datetime_2001_09_10(self): self.assertEqual(WeeklyTime.from_datetime(c.dt_2001_09_10), c.monday_0000)
def test_first_datetime_after_six_day_difference(self): wt = WeeklyTime(1, 0, 0) first_dt_after = wt.first_datetime_after(datetime(2017, 1, 31, 17, 44)) self.assertEqual(first_dt_after, datetime(2017, 2, 6, 0, 0))
from availability import Availability from match import Match from user import User from weekly_time import WeeklyTime # Numerical constants SLOTS_PER_WEEK = 672 MINUTES_PER_HOUR = 60 HOURS_PER_DAY = 24 DAYS_PER_WEEK = 7 MINUTES_PER_DAY = MINUTES_PER_HOUR * HOURS_PER_DAY MINUTES_PER_WEEK = MINUTES_PER_DAY * DAYS_PER_WEEK # WeeklyTime objects sunday_0000 = WeeklyTime(0, 0, 0) sunday_0115 = WeeklyTime(0, 1, 15) sunday_0215 = WeeklyTime(0, 2, 15) sunday_0059 = WeeklyTime(0, 0, 59) sunday_2300 = WeeklyTime(0, 23, 0) monday_0000 = WeeklyTime(1, 0, 0) tuesday_1715 = WeeklyTime(2, 17, 15) thursday_0630 = WeeklyTime(4, 6, 30) saturday_0000 = WeeklyTime(6, 0, 0) saturday_2345 = WeeklyTime(6, 23, 45) # Free slot boolean arrays always_free_slots = [True for i in range(SLOTS_PER_WEEK)] free_first_five_slots = [ True if i < 5 else False for i in range(SLOTS_PER_WEEK) ]