diff --git a/db/dummy_data.sql b/db/dummy_data.sql index 3ee1948..eb43feb 100644 --- a/db/dummy_data.sql +++ b/db/dummy_data.sql @@ -13,7 +13,7 @@ UNLOCK TABLES; LOCK TABLES `team` WRITE; /*!40000 ALTER TABLE `team` DISABLE KEYS */; -INSERT INTO `team` VALUES (1,'Test Team','#team','team@example.com','US/Pacific',1,NULL); +INSERT INTO `team` VALUES (1,'Test Team','#team','team@example.com','US/Pacific',1,NULL,0); /*!40000 ALTER TABLE `team` ENABLE KEYS */; UNLOCK TABLES; @@ -48,14 +48,14 @@ UNLOCK TABLES; LOCK TABLES `roster_user` WRITE; /*!40000 ALTER TABLE `roster_user` DISABLE KEYS */; -INSERT INTO `roster_user` VALUES (1,3,1),(1,4,1),(2,2,1); +INSERT INTO `roster_user` VALUES (1,3,1,0),(1,4,1,1),(2,2,1,0); /*!40000 ALTER TABLE `roster_user` ENABLE KEYS */; UNLOCK TABLES; LOCK TABLES `schedule` WRITE; /*!40000 ALTER TABLE `schedule` DISABLE KEYS */; -INSERT INTO `schedule` VALUES (1,1,1,1,21,0,1496559600),(2,1,2,4,21,0,1496559600); +INSERT INTO `schedule` VALUES (1,1,1,1,21,0,1496559600,1),(2,1,2,4,21,0,1496559600,1); /*!40000 ALTER TABLE `schedule` ENABLE KEYS */; UNLOCK TABLES; diff --git a/db/schema.v0.sql b/db/schema.v0.sql index a98f01a..bb78e19 100644 --- a/db/schema.v0.sql +++ b/db/schema.v0.sql @@ -124,6 +124,16 @@ VALUES ('primary', 1), ('vacation', 5), ('unavailable', 6); +-- ----------------------------------------------------- +-- Table `scheduler`` +-- ----------------------------------------------------- +CREATE TABLE IF NOT EXISTS `scheduler` ( + `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL UNIQUE, + `description` TEXT NOT NULL, + PRIMARY KEY (`id`) +); + -- ----------------------------------------------------- -- Table `schedule` -- ----------------------------------------------------- @@ -138,6 +148,8 @@ CREATE TABLE IF NOT EXISTS `schedule` ( -- 1: display schedule in "advanced mode" (individual events) `advanced_mode` TINYINT(1) NOT NULL, `last_epoch_scheduled` BIGINT(20) UNSIGNED, + `last_scheduled_user_id` BIGINT(20) UNSIGNED, + `scheduler_id` INT(11) UNSIGNED NOT NULL, PRIMARY KEY (`id`), INDEX `schedule_roster_id_idx` (`roster_id` ASC), INDEX `schedule_role_id_idx` (`role_id` ASC), @@ -156,7 +168,18 @@ CREATE TABLE IF NOT EXISTS `schedule` ( FOREIGN KEY (`team_id`) REFERENCES `team` (`id`) ON DELETE CASCADE - ON UPDATE CASCADE); + ON UPDATE CASCADE, + CONSTRAINT `schedule_scheduler_id_fk` + FOREIGN KEY (`scheduler_id`) + REFERENCES `scheduler` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION, + CONSTRAINT `schedule_last_user_id_fk` + FOREIGN KEY (`last_scheduled_user_id`) + REFERENCES `user` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION +); -- ----------------------------------------------------- -- Table `schedule_event` @@ -247,6 +270,7 @@ CREATE TABLE IF NOT EXISTS `roster_user` ( `roster_id` BIGINT(20) UNSIGNED NOT NULL, `user_id` BIGINT(20) UNSIGNED NOT NULL, `in_rotation` BOOLEAN NOT NULL DEFAULT 1, + `roster_priority` INT(11) UNSIGNED NOT NULL, PRIMARY KEY (`roster_id`, `user_id`), INDEX `roster_user_user_id_fk_idx` (`user_id` ASC), CONSTRAINT `roster_user_user_id_fk` @@ -393,6 +417,14 @@ CREATE TABLE IF NOT EXISTS `application` ( PRIMARY KEY (`id`) ); +INSERT INTO `scheduler` ( `name`, `description`) +VALUES ('default', + 'Default scheduling algorithm'), + ('round-robin', + 'Round robin in roster order; does not respect vacations/conflicts'), + ('no-skip-matching', + 'Default scheduling algorithm; doesn\'t skips creating events if matching events already exist on the calendar'); + -- ----------------------------------------------------- -- Initialize notification types -- ----------------------------------------------------- diff --git a/e2e/test_populate.py b/e2e/test_populate.py index 5264ad7..d230290 100644 --- a/e2e/test_populate.py +++ b/e2e/test_populate.py @@ -122,3 +122,98 @@ def test_api_v0_populate_invalid(user, team, roster, role, schedule): assert re.status_code == 200 re = requests.get(api_v0('schedules/%s' % schedule_id)) assert re.json() == schedule_json + + +@prefix('test_v0_round_robin') +def test_api_v0_round_robin(user, team, roster, role, schedule, event): + user_name = user.create() + user_name_2 = user.create() + user_name_3 = user.create() + team_name = team.create() + role_name = role.create() + roster_name = roster.create(team_name) + schedule_id = schedule.create(team_name, + roster_name, + {'role': role_name, + 'events': [{'start': 0, 'duration': 604800}], + 'advanced_mode': 0, + 'auto_populate_threshold': 28, + 'scheduler': 'round-robin'}) + user.add_to_roster(user_name, team_name, roster_name) + user.add_to_roster(user_name_2, team_name, roster_name) + user.add_to_roster(user_name_3, team_name, roster_name) + + def clean_up(): + re = requests.get(api_v0('events?team='+team_name)) + for ev in re.json(): + requests.delete(api_v0('events/%d' % ev['id'])) + + clean_up() + + start = time.time() + # Create an event for user 1 + event.create({'start': start, + 'end': start + 1000, + 'user': user_name, + 'team': team_name, + 'role': role_name}) + + re = requests.post(api_v0('schedules/%s/populate' % schedule_id), json = {'start': start + 2000}) + assert re.status_code == 200 + + re = requests.get(api_v0('events?team=%s' % team_name)) + assert re.status_code == 200 + events = re.json() + # Check that newly populated events start with user 2, then loop back to user 1 + assert events[1]['user'] == user_name_2 + assert events[2]['user'] == user_name_3 + assert events[3]['user'] == user_name + + clean_up() + + +# Test skipping over matching events +@prefix('test_v0_populate_skip') +def test_api_v0_populate_skip(user, team, roster, role, schedule, event): + user_name = user.create() + user_name_2 = user.create() + team_name = team.create() + role_name = role.create() + roster_name = roster.create(team_name) + schedule_id = schedule.create(team_name, + roster_name, + {'role': role_name, + 'events': [{'start': 0, 'duration': 604800}], + 'advanced_mode': 0, + 'auto_populate_threshold': 14}) + user.add_to_roster(user_name, team_name, roster_name) + user.add_to_roster(user_name_2, team_name, roster_name) + + + re = requests.post(api_v0('schedules/%s/populate' % schedule_id), json = {'start': time.time()}) + assert re.status_code == 200 + + re = requests.get(api_v0('events?team=%s' % team_name)) + assert re.status_code == 200 + events = re.json() + assert len(events) == 2 + + event.create({ + 'start': events[0]['start'], + 'end': events[0]['end'], + 'user': user_name, + 'team': team_name, + 'role': role_name, + }) + + re = requests.post(api_v0('schedules/%s/populate' % schedule_id), json = {'start': time.time()}) + assert re.status_code == 200 + + re = requests.get(api_v0('events?team=%s' % team_name)) + assert re.status_code == 200 + events = re.json() + assert len(events) == 2 + + schedule_ids = set([ev['schedule_id'] for ev in events]) + assert None in schedule_ids + assert schedule_id in schedule_ids \ No newline at end of file diff --git a/e2e/test_rosters.py b/e2e/test_rosters.py index fc3f69d..60a36d4 100644 --- a/e2e/test_rosters.py +++ b/e2e/test_rosters.py @@ -197,6 +197,36 @@ def test_api_v0_rotation(team, user, roster): assert isinstance(users, list) assert set(users) == set([user_name]) + +@prefix('test_v0_roster_order') +def test_api_v0_roster_order(team, user, roster): + team_name = team.create() + user_name = user.create() + user_name_2 = user.create() + user_name_3 = user.create() + roster_name = roster.create(team_name) + user.add_to_roster(user_name, team_name, roster_name) + user.add_to_roster(user_name_2, team_name, roster_name) + user.add_to_roster(user_name_3, team_name, roster_name) + + re = requests.get(api_v0('teams/%s/rosters/%s' % (team_name, roster_name))) + data = re.json() + users = {u['name']: u for u in data['users']} + assert users[user_name]['roster_priority'] == 0 + assert users[user_name_2]['roster_priority'] == 1 + assert users[user_name_3]['roster_priority'] == 2 + + re = requests.put(api_v0('teams/%s/rosters/%s' % (team_name, roster_name)), + json={'roster_order': [user_name_3, user_name_2, user_name]}) + assert re.status_code == 200 + re = requests.get(api_v0('teams/%s/rosters/%s' % (team_name, roster_name))) + data = re.json() + users = {u['name']: u for u in data['users']} + assert users[user_name]['roster_priority'] == 2 + assert users[user_name_2]['roster_priority'] == 1 + assert users[user_name_3]['roster_priority'] == 0 + + # TODO: test invalid user or team # TODO: test out of rotation diff --git a/src/oncall/api/v0/populate.py b/src/oncall/api/v0/populate.py index f1b4dcd..c2b627d 100644 --- a/src/oncall/api/v0/populate.py +++ b/src/oncall/api/v0/populate.py @@ -5,12 +5,8 @@ from ... import db from ...utils import load_json_body from ...auth import check_team_auth, login_required from schedules import get_schedules -from ...bin.scheduler import calculate_future_events, epoch_from_datetime, \ - create_events, find_least_active_available_user_id, get_period_len, set_last_epoch -from datetime import datetime, timedelta -from falcon import HTTPBadRequest -from pytz import timezone, utc - +from falcon import HTTPNotFound +from oncall.bin.scheduler import load_scheduler @login_required def on_post(req, resp, schedule_id): @@ -35,48 +31,16 @@ def on_post(req, resp, schedule_id): # TODO: add images to docstring because it doesn't make sense data = load_json_body(req) start_time = data['start'] - start_dt = datetime.fromtimestamp(start_time, utc) - start_epoch = epoch_from_datetime(start_dt) - - # Get schedule info - schedule = get_schedules({'id': schedule_id})[0] - role_id = schedule['role_id'] - team_id = schedule['team_id'] - roster_id = schedule['roster_id'] - first_event_start = min(schedule['events'], key=lambda x: x['start'])['start'] - period = get_period_len(schedule) - handoff = start_epoch + timedelta(seconds=first_event_start) - handoff = timezone(schedule['timezone']).localize(handoff) - - # Start scheduling from the next occurrence of the hand-off time. - if start_dt > handoff: - start_epoch += timedelta(weeks=period) - handoff += timedelta(weeks=period) - if handoff < utc.localize(datetime.utcnow()): - raise HTTPBadRequest('Invalid populate request', 'cannot populate starting in the past') - - check_team_auth(schedule['team'], req) connection = db.connect() cursor = connection.cursor(db.DictCursor) - - future_events, last_epoch = calculate_future_events(schedule, cursor, start_epoch) - set_last_epoch(schedule_id, last_epoch, cursor) - - # Delete existing events from the start of the first event - future_events = [filter(lambda x: x['start'] >= start_time, evs) for evs in future_events] - future_events = filter(lambda x: x != [], future_events) - if future_events: - first_event_start = min(future_events[0], key=lambda x: x['start'])['start'] - cursor.execute('DELETE FROM event WHERE schedule_id = %s AND start >= %s', (schedule_id, first_event_start)) - - # Create events in the db, associating a user to them - for epoch in future_events: - user_id = find_least_active_available_user_id(team_id, role_id, roster_id, epoch, cursor) - if not user_id: - continue - create_events(team_id, schedule['id'], user_id, epoch, role_id, cursor) - - connection.commit() + cursor.execute('SELECT `scheduler`.`name` FROM `schedule` JOIN `scheduler` ON `schedule`.`scheduler_id` = `scheduler`.`id` WHERE `schedule`.`id` = %s', schedule_id) + if cursor.rowcount == 0: + raise HTTPNotFound() + scheduler_name = cursor.fetchone()['name'] + scheduler = load_scheduler(scheduler_name) + schedule = get_schedules({'id': schedule_id})[0] + check_team_auth(schedule['team'], req) + scheduler.populate(schedule, start_time, (connection, cursor)) cursor.close() connection.close() diff --git a/src/oncall/api/v0/roster.py b/src/oncall/api/v0/roster.py index 1f0f77d..3265a2c 100644 --- a/src/oncall/api/v0/roster.py +++ b/src/oncall/api/v0/roster.py @@ -76,7 +76,8 @@ def on_get(req, resp, team, roster): roster_id = results[0]['roster'] # get list of users in the roster cursor.execute('''SELECT `user`.`name` as `name`, - `roster_user`.`in_rotation` AS `in_rotation` + `roster_user`.`in_rotation` AS `in_rotation`, + `roster_user`.`roster_priority` FROM `roster_user` JOIN `user` ON `roster_user`.`user_id`=`user`.`id` WHERE `roster_user`.`roster_id`=%s''', roster_id) @@ -111,27 +112,47 @@ def on_put(req, resp, team, roster): team, roster = unquote(team), unquote(roster) data = load_json_body(req) name = data.get('name') + roster_order = data.get('roster_order') check_team_auth(team, req) - if not name: - raise HTTPBadRequest('invalid team name', 'team name is missing') - if name == roster: - return - invalid_char = invalid_char_reg.search(name) - if invalid_char: - raise HTTPBadRequest('invalid team name', - 'team name contains invalid character "%s"' % invalid_char.group()) + if not (name or roster_order): + raise HTTPBadRequest('invalid roster update', 'missing roster name or order') connection = db.connect() cursor = connection.cursor() try: - cursor.execute( - '''UPDATE `roster` SET `name`=%s - WHERE `team_id`=(SELECT `id` FROM `team` WHERE `name`=%s) - AND `name`=%s''', - (name, team, roster)) - create_audit({'old_name': roster, 'new_name': name}, team, ROSTER_EDITED, req, cursor) - connection.commit() + if roster_order: + cursor.execute('''SELECT `user`.`name` FROM `roster_user` + JOIN `roster` ON `roster`.`id` = `roster_user`.`roster_id` + JOIN `user` ON `roster_user`.`user_id` = `user`.`id` + WHERE `roster_id` = (SELECT id FROM roster WHERE name = %s + AND team_id = (SELECT id from team WHERE name = %s))''', + (roster, team)) + roster_users = {row[0] for row in cursor} + if not all(map(lambda x: x in roster_users, roster_order)): + raise HTTPBadRequest('Invalid roster order', 'All users in provided order must be part of the roster') + if not len(roster_order) == len(roster_users): + raise HTTPBadRequest('Invalid roster order', 'Roster order must include all roster members') + + cursor.executemany('''UPDATE roster_user SET roster_priority = %s + WHERE roster_id = (SELECT id FROM roster WHERE name = %s + AND team_id = (SELECT id FROM team WHERE name = %s)) + AND user_id = (SELECT id FROM user WHERE name = %s)''', + ((idx, roster, team, user) for idx, user in enumerate(roster_order))) + connection.commit() + + if name and name != roster: + invalid_char = invalid_char_reg.search(name) + if invalid_char: + raise HTTPBadRequest('invalid roster name', + 'roster name contains invalid character "%s"' % invalid_char.group()) + cursor.execute( + '''UPDATE `roster` SET `name`=%s + WHERE `team_id`=(SELECT `id` FROM `team` WHERE `name`=%s) + AND `name`=%s''', + (name, team, roster)) + create_audit({'old_name': roster, 'new_name': name}, team, ROSTER_EDITED, req, cursor) + connection.commit() except db.IntegrityError as e: err_msg = str(e.args[1]) if 'Duplicate entry' in err_msg: diff --git a/src/oncall/api/v0/roster_users.py b/src/oncall/api/v0/roster_users.py index 9bf3b1a..4b91185 100644 --- a/src/oncall/api/v0/roster_users.py +++ b/src/oncall/api/v0/roster_users.py @@ -2,7 +2,7 @@ # See LICENSE in the project root for license information. from urllib import unquote -from falcon import HTTPError, HTTP_201, HTTPBadRequest +from falcon import HTTPError, HTTP_201, HTTPBadRequest, HTTPNotFound from ujson import dumps as json_dumps from ...auth import login_required, check_team_auth @@ -121,15 +121,23 @@ def on_post(req, resp, team, roster): # also make sure user is in the team cursor.execute('''INSERT IGNORE INTO `team_user` (`team_id`, `user_id`) VALUES (%r, %r)''', (team_id, user_id)) - cursor.execute('''INSERT INTO `roster_user` (`user_id`, `roster_id`, `in_rotation`) + cursor.execute('''SELECT `roster`.`id`, COALESCE(MAX(`roster_user`.`roster_priority`), -1) + 1 + FROM `roster_user` + JOIN `roster` ON `roster_id` = `roster`.`id` + JOIN `team` ON `team`.`id`=`roster`.`team_id` + WHERE `team`.`name`=%s AND `roster`.`name`=%s''', + (team, roster)) + if cursor.rowcount == 0: + raise HTTPNotFound() + roster_id, roster_priority = cursor.fetchone() + cursor.execute('''INSERT INTO `roster_user` (`user_id`, `roster_id`, `in_rotation`, `roster_priority`) VALUES ( - %r, - (SELECT `roster`.`id` FROM `roster` - JOIN `team` ON `team`.`id`=`roster`.`team_id` - WHERE `team`.`name`=%s AND `roster`.`name`=%s), - %s + %s, + %s, + %s, + %s )''', - (user_id, team, roster, in_rotation)) + (user_id, roster_id, in_rotation, roster_priority)) # subscribe user to notifications subscribe_notifications(team, user_name, cursor) diff --git a/src/oncall/api/v0/schedules.py b/src/oncall/api/v0/schedules.py index 1e9c846..3cadb9d 100644 --- a/src/oncall/api/v0/schedules.py +++ b/src/oncall/api/v0/schedules.py @@ -22,7 +22,8 @@ columns = { 'team': '`team`.`name` as `team`, `team`.`id` AS `team_id`', 'events': '`schedule_event`.`start`, `schedule_event`.`duration`, `schedule`.`id` AS `schedule_id`', 'advanced_mode': '`schedule`.`advanced_mode` AS `advanced_mode`', - 'timezone': '`team`.`scheduling_timezone` AS `timezone`' + 'timezone': '`team`.`scheduling_timezone` AS `timezone`', + 'scheduler': '`scheduler`.`name` AS `scheduler`' } all_columns = columns.keys() @@ -95,6 +96,8 @@ def get_schedules(filter_params, dbinfo=None, fields=None): from_clause.append('JOIN `team` ON `team`.`id` = `schedule`.`team_id`') if 'role' in fields: from_clause.append('JOIN `role` ON `role`.`id` = `schedule`.`role_id`') + if 'scheduler' in fields: + from_clause.append('JOIN `scheduler` ON `scheduler`.`id` = `schedule`.`scheduler_id`') if 'events' in fields: from_clause.append('LEFT JOIN `schedule_event` ON `schedule_event`.`schedule_id` = `schedule`.`id`') events = True @@ -362,19 +365,24 @@ def on_post(req, resp, team, roster): # default to autopopulate 3 weeks forward data['auto_populate_threshold'] = 21 + if 'scheduler' not in data: + # default to "default" scheduling algorithm + data['scheduler'] = 'default' + if not data['advanced_mode']: if not validate_simple_schedule(schedule_events): raise HTTPBadRequest('invalid schedule', 'invalid advanced mode setting') insert_schedule = '''INSERT INTO `schedule` (`roster_id`,`team_id`,`role_id`, - `auto_populate_threshold`, `advanced_mode`) + `auto_populate_threshold`, `advanced_mode`, `scheduler_id`) VALUES ((SELECT `roster`.`id` FROM `roster` JOIN `team` ON `roster`.`team_id` = `team`.`id` WHERE `roster`.`name` = %(roster)s AND `team`.`name` = %(team)s), (SELECT `id` FROM `team` WHERE `name` = %(team)s), (SELECT `id` FROM `role` WHERE `name` = %(role)s), %(auto_populate_threshold)s, - %(advanced_mode)s)''' + %(advanced_mode)s, + (SELECT `id` FROM `scheduler` WHERE `name` = %(scheduler)s))''' connection = db.connect() cursor = connection.cursor(db.DictCursor) try: @@ -385,6 +393,12 @@ def on_post(req, resp, team, roster): err_msg = str(e.args[1]) if err_msg == 'Column \'roster_id\' cannot be null': err_msg = 'roster "%s" not found' % roster + elif err_msg == 'Column \'role_id\' cannot be null': + err_msg = 'role not found' + elif err_msg == 'Column \'scheduler_id\' cannot be null': + err_msg = 'scheduler not found' + elif err_msg == 'Column \'team_id\' cannot be null': + err_msg = 'team "%s" not found' % team raise HTTPError('422 Unprocessable Entity', 'IntegrityError', err_msg) connection.commit() diff --git a/src/oncall/bin/scheduler.py b/src/oncall/bin/scheduler.py index 443a670..c5681c4 100644 --- a/src/oncall/bin/scheduler.py +++ b/src/oncall/bin/scheduler.py @@ -9,14 +9,14 @@ from __future__ import print_function import sys import time -from oncall.utils import gen_link_id -from datetime import datetime, timedelta -from pytz import timezone, utc +import importlib +from collections import defaultdict from oncall import db, utils from oncall.api.v0.schedules import get_schedules import logging + logger = logging.getLogger() handler = logging.StreamHandler() formatter = logging.Formatter('%(asctime)s %(name)-6s %(levelname)-8s %(message)s') @@ -26,257 +26,9 @@ logger.setLevel(logging.DEBUG) logging.getLogger('requests').setLevel(logging.WARN) -UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=utc) -SECONDS_IN_A_DAY = 24 * 60 * 60 -SECONDS_IN_A_WEEK = SECONDS_IN_A_DAY * 7 - -def get_role_id(role_name, cursor): - cursor.execute('SELECT `id` FROM `role` WHERE `name` = %s', role_name) - role_id = cursor.fetchone()['id'] - return role_id - - -def get_schedule_last_event_end(schedule, cursor): - cursor.execute('SELECT `end` FROM `event` WHERE `schedule_id` = %r ORDER BY `end` DESC LIMIT 1', - schedule['id']) - if cursor.rowcount != 0: - return cursor.fetchone()['end'] - else: - return None - - -def get_schedule_last_epoch(schedule, cursor): - cursor.execute('SELECT `last_epoch_scheduled` FROM `schedule` WHERE `id` = %s', - schedule['id']) - if cursor.rowcount != 0: - return cursor.fetchone()['last_epoch_scheduled'] - else: - return None - - -def get_roster_user_ids(roster_id, cursor): - cursor.execute(''' - SELECT `roster_user`.`user_id` FROM `roster_user` - JOIN `user` ON `user`.`id` = `roster_user`.`user_id` - WHERE `roster_user`.`in_rotation` = 1 AND `roster_user`.`roster_id` = %r - AND `user`.`active` = TRUE''', roster_id) - return [r['user_id'] for r in cursor] - - -def get_busy_user_by_event_range(user_ids, team_id, events, cursor): - ''' Find which users have overlapping events for the same team in this time range''' - range_check = [] - for e in events: - range_check.append('(%r < `end` AND `start` < %r)' % (e['start'], e['end'])) - - query = ''' - SELECT DISTINCT `user_id` FROM `event` - WHERE `user_id` in %%s AND team_id = %%s AND (%s) - ''' % ' OR '.join(range_check) - - cursor.execute(query, (user_ids, team_id)) - return [r['user_id'] for r in cursor.fetchall()] - - -def find_least_active_user_id_by_team(user_ids, team_id, start_time, role_id, cursor): - ''' - Of the people who have been oncall before, finds those who haven't been oncall for the longest. Start - time refers to the start time of the event being created, so we don't accidentally look at future - events when determining who was oncall in the past. Done on a per-role basis, so we don't take manager - or vacation shifts into account - ''' - cursor.execute(''' - SELECT `user_id`, MAX(`end`) AS `last_end` FROM `event` - WHERE `team_id` = %s AND `user_id` IN %s AND `end` <= %s - AND `role_id` = %s - GROUP BY `user_id` - ''', (team_id, user_ids, start_time, role_id)) - if cursor.rowcount != 0: - # Grab user id with lowest last scheduled time - return min(cursor.fetchall(), key=lambda x: x['last_end'])['user_id'] - else: - return None - - -def find_new_user_in_roster(roster_id, team_id, start_time, role_id, cursor): - ''' - Return roster users who haven't been scheduled for any event on this team's calendar for this schedule's role. - Ignores events from other teams. - ''' - query = ''' - SELECT DISTINCT `user`.`id` FROM `roster_user` - JOIN `user` ON `user`.`id` = `roster_user`.`user_id` AND `roster_user`.`roster_id` = %s - LEFT JOIN `event` ON `event`.`user_id` = `user`.`id` AND `event`.`team_id` = %s AND `event`.`end` <= %s - AND `event`.`role_id` = %s - WHERE `roster_user`.`in_rotation` = 1 AND `event`.`id` IS NULL - ''' - cursor.execute(query, (roster_id, team_id, start_time, role_id)) - if cursor.rowcount != 0: - logger.debug('Found new guy') - return set(row['id'] for row in cursor) - - -def create_events(team_id, schedule_id, user_id, events, role_id, cursor): - if len(events) == 1: - [event] = events - event_args = (team_id, schedule_id, event['start'], event['end'], user_id, role_id) - logger.debug('inserting event: %s', event_args) - query = ''' - INSERT INTO `event` ( - `team_id`, `schedule_id`, `start`, `end`, `user_id`, `role_id` - ) VALUES ( - %s, %s, %s, %s, %s, %s - )''' - cursor.execute(query, event_args) - else: - link_id = gen_link_id() - for event in events: - event_args = (team_id, schedule_id, event['start'], event['end'], user_id, role_id, link_id) - logger.debug('inserting event: %s', event_args) - query = ''' - INSERT INTO `event` ( - `team_id`, `schedule_id`, `start`, `end`, `user_id`, `role_id`, `link_id` - ) VALUES ( - %s, %s, %s, %s, %s, %s, %s - )''' - cursor.execute(query, event_args) - - -def set_last_epoch(schedule_id, last_epoch, cursor): - cursor.execute('UPDATE `schedule` SET `last_epoch_scheduled` = %s WHERE `id` = %s', - (last_epoch, schedule_id)) - - -# End of DB interactions - -def weekday_from_schedule_time(schedule_time): - '''Returns 0 for Monday, 1 for Tuesday...''' - return (schedule_time / SECONDS_IN_A_DAY - 1) % 7 - - -def epoch_from_datetime(dt): - ''' - Given timezoned or naive datetime, returns a naive datetime for 00:00:00 on the - first Sunday before the given date - ''' - sunday = dt + timedelta(days=(-(dt.isoweekday() % 7))) - epoch = sunday.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) - return epoch - - -def get_closest_epoch(dt): - ''' - Given naive datetime, returns naive datetime of the closest epoch (Sunday midnight) - ''' - dt = dt.replace(tzinfo=None) - before_sunday = dt + timedelta(days=(-(dt.isoweekday() % 7))) - before = before_sunday.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) - - after_sunday = dt + timedelta(days=7 - dt.isoweekday()) - after = after_sunday.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) - - before_diff = dt - before - after_diff = after - dt - if before_diff < after_diff: - return before - else: - return after - - -def utc_from_naive_date(date, schedule): - tz = timezone(schedule['timezone']) - # Arbitrarily choose ambiguous/nonexistent dates to be in DST. Results in no gaps in a schedule given - # a consistent arbitrary choice. - date = (tz.localize(date, is_dst=1)).astimezone(utc) - td = date - UNIX_EPOCH - # Convert timedelta to seconds - return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10**6 - - -def generate_events(schedule, schedule_events, epoch): - generated = [] - for event in schedule_events: - start = timedelta(seconds=event['start']) + epoch - # Need to calculate naive end date to correct for DST - end = timedelta(seconds=event['start'] + event['duration']) + epoch - start = utc_from_naive_date(start, schedule) - end = utc_from_naive_date(end, schedule) - generated.append({'start': start, 'end': end}) - return generated - - -def get_period_len(schedule): - ''' - Find schedule rotation period in weeks, rounded up - ''' - events = schedule['events'] - first_event = min(events, key=lambda x: x['start']) - end = max(e['start'] + e['duration'] for e in events) - period = end - first_event['start'] - return ((period + SECONDS_IN_A_WEEK - 1) / SECONDS_IN_A_WEEK) - - -def calculate_future_events(schedule, cursor, start_epoch=None): - period = get_period_len(schedule) - - # DEFINITION: - # epoch: Sunday at 00:00:00 in the schedule's local timezone. This is our point of reference when - # populating events. Why not UTC? DST. - - # Find where to start scheduling - if start_epoch is None: - last_epoch_timestamp = get_schedule_last_epoch(schedule, cursor) - - # Handle new schedules, start scheduling from current week - if last_epoch_timestamp is None: - start_dt = datetime.fromtimestamp(time.time(), utc).astimezone(timezone(schedule['timezone'])) - next_epoch = epoch_from_datetime(start_dt) - else: - # Otherwise, find the next epoch (NOTE: can't assume that last_epoch_timestamp is Sunday 00:00:00 in the - # schedule's timezone, because the scheduling timezone might have changed. Instead, find the closest - # epoch and work from there) - last_epoch_dt = datetime.fromtimestamp(last_epoch_timestamp, utc) - localized_last_epoch = last_epoch_dt.astimezone(timezone(schedule['timezone'])) - next_epoch = get_closest_epoch(localized_last_epoch) + timedelta(days=7 * period) - else: - next_epoch = start_epoch - - cutoff_date = datetime.fromtimestamp(time.time(), utc) + timedelta(days=schedule['auto_populate_threshold']) - cutoff_date = cutoff_date.replace(tzinfo=None) - future_events = [] - # Start scheduling from the next epoch - while cutoff_date > next_epoch: - epoch_events = generate_events(schedule, schedule['events'], next_epoch) - next_epoch += timedelta(days=7 * period) - if epoch_events: - future_events.append(epoch_events) - # Return future events and the last epoch events were scheduled for. - return future_events, utc_from_naive_date(next_epoch - timedelta(days=7 * period), schedule) - - -def find_least_active_available_user_id(team_id, role_id, roster_id, future_events, cursor): - # find people without conflicting events - # TODO: finer grain conflict checking - user_ids = set(get_roster_user_ids(roster_id, cursor)) - if not user_ids: - logger.info('Empty roster, skipping') - return None - logger.debug('filtering users: %s', user_ids) - start = min([e['start'] for e in future_events]) - for uid in get_busy_user_by_event_range(user_ids, team_id, future_events, cursor): - user_ids.remove(uid) - if not user_ids: - logger.info('All users have conflicting events, skipping...') - return None - new_user_ids = find_new_user_in_roster(roster_id, team_id, start, role_id, cursor) - available_and_new = new_user_ids & user_ids - if available_and_new: - logger.info('Picking new and available user from %s', available_and_new) - return available_and_new.pop() - - logger.debug('picking user between: %s, team: %s', user_ids, team_id) - return find_least_active_user_id_by_team(user_ids, team_id, start, role_id, cursor) +def load_scheduler(scheduler_name): + return importlib.import_module('oncall.scheduler.' + scheduler_name).Scheduler() def main(): @@ -284,51 +36,36 @@ def main(): db.init(config['db']) cycle_time = config.get('scheduler_cycle_time', 3600) + schedulers = {} while 1: connection = db.connect() db_cursor = connection.cursor(db.DictCursor) start = time.time() + # Load all schedulers + db_cursor.execute('SELECT name FROM scheduler') + schedulers = {} + for row in db_cursor: + try: + scheduler_name = row['name'] + if scheduler_name not in schedulers: + schedulers[scheduler_name] = load_scheduler(scheduler_name) + except (ImportError, AttributeError): + logger.exception('Failed to load scheduler %s, skipping', row['name']) + # Iterate through all teams db_cursor.execute('SELECT id, name, scheduling_timezone FROM team WHERE active = TRUE') teams = db_cursor.fetchall() for team in teams: - team_id = team['id'] - # Get rosters for team - db_cursor.execute('SELECT `id`, `name` FROM `roster` WHERE `team_id` = %s', team_id) - rosters = db_cursor.fetchall() - if db_cursor.rowcount == 0: - continue logger.info('scheduling for team: %s', team['name']) - events = [] - for roster in rosters: - roster_id = roster['id'] - # Get schedules for each roster - schedules = get_schedules({'team_id': team_id, 'roster_id': roster_id}) - for schedule in schedules: - if schedule['auto_populate_threshold'] <= 0: - continue - logger.info('\t\tschedule: %s', str(schedule['id'])) - schedule['timezone'] = team['scheduling_timezone'] - # Calculate events for schedule - future_events, last_epoch = calculate_future_events(schedule, db_cursor) - role_id = get_role_id(schedule['role'], db_cursor) - for epoch in future_events: - # Add (start_time, schedule_id, role_id, roster_id, epoch_events) to events - events.append((min([ev['start'] for ev in epoch]), schedule['id'], role_id, roster_id, epoch)) - set_last_epoch(schedule['id'], last_epoch, db_cursor) - # Create events in the db, associating a user to them - # Iterate through events in order of start time to properly assign users - for event_info in sorted(events, key=lambda x: x[0]): - _, schedule_id, role_id, roster_id, epoch = event_info - user_id = find_least_active_available_user_id(team_id, role_id, roster_id, epoch, db_cursor) - if not user_id: - logger.info('Failed to find available user') - continue - logger.info('Found user: %s', user_id) - create_events(team_id, schedule_id, user_id, epoch, role_id, db_cursor) - connection.commit() + schedule_map = defaultdict(list) + for schedule in get_schedules({'team_id': team['id']}): + schedule_map[schedule['scheduler']].append(schedule) + + for scheduler_name, schedules in schedule_map.iteritems(): + schedulers[scheduler_name].schedule(team, schedules, (connection, db_cursor)) + # Sleep until next time sleep_time = cycle_time - (time.time() - start) if sleep_time > 0: diff --git a/src/oncall/scheduler/__init__.py b/src/oncall/scheduler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/oncall/scheduler/default.py b/src/oncall/scheduler/default.py new file mode 100644 index 0000000..e695387 --- /dev/null +++ b/src/oncall/scheduler/default.py @@ -0,0 +1,334 @@ +from datetime import datetime, timedelta +from pytz import timezone, utc +from oncall.utils import gen_link_id +from falcon import HTTPBadRequest +import time +import logging +import operator + +logger = logging.getLogger() + +UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=utc) +SECONDS_IN_A_DAY = 24 * 60 * 60 +SECONDS_IN_A_WEEK = SECONDS_IN_A_DAY * 7 + + +class Scheduler(object): + def __init__(self): + pass + + # DB interactions + def get_role_id(self, role_name, cursor): + cursor.execute('SELECT `id` FROM `role` WHERE `name` = %s', role_name) + role_id = cursor.fetchone()['id'] + return role_id + + def get_schedule_last_event_end(self, schedule, cursor): + cursor.execute('SELECT `end` FROM `event` WHERE `schedule_id` = %r ORDER BY `end` DESC LIMIT 1', + schedule['id']) + if cursor.rowcount != 0: + return cursor.fetchone()['end'] + else: + return None + + def get_schedule_last_epoch(self, schedule, cursor): + cursor.execute('SELECT `last_epoch_scheduled` FROM `schedule` WHERE `id` = %s', + schedule['id']) + if cursor.rowcount != 0: + return cursor.fetchone()['last_epoch_scheduled'] + else: + return None + + def get_roster_user_ids(self, roster_id, cursor): + cursor.execute(''' + SELECT `roster_user`.`user_id` FROM `roster_user` + JOIN `user` ON `user`.`id` = `roster_user`.`user_id` + WHERE `roster_user`.`in_rotation` = 1 AND `roster_user`.`roster_id` = %r + AND `user`.`active` = TRUE''', roster_id) + return [r['user_id'] for r in cursor] + + def get_busy_user_by_event_range(self, user_ids, team_id, events, cursor): + ''' Find which users have overlapping events for the same team in this time range''' + range_check = [] + for e in events: + range_check.append('(%r < `end` AND `start` < %r)' % (e['start'], e['end'])) + + query = ''' + SELECT DISTINCT `user_id` FROM `event` + WHERE `user_id` in %%s AND team_id = %%s AND (%s) + ''' % ' OR '.join(range_check) + + cursor.execute(query, (user_ids, team_id)) + return [r['user_id'] for r in cursor.fetchall()] + + def find_least_active_user_id_by_team(self, user_ids, team_id, start_time, role_id, cursor): + ''' + Of the people who have been oncall before, finds those who haven't been oncall for the longest. Start + time refers to the start time of the event being created, so we don't accidentally look at future + events when determining who was oncall in the past. Done on a per-role basis, so we don't take manager + or vacation shifts into account + ''' + cursor.execute(''' + SELECT `user_id`, MAX(`end`) AS `last_end` FROM `event` + WHERE `team_id` = %s AND `user_id` IN %s AND `end` <= %s + AND `role_id` = %s + GROUP BY `user_id` + ''', (team_id, user_ids, start_time, role_id)) + if cursor.rowcount != 0: + # Grab user id with lowest last scheduled time + return min(cursor.fetchall(), key=operator.itemgetter('last_end'))['user_id'] + else: + return None + + def find_new_user_in_roster(self, roster_id, team_id, start_time, role_id, cursor): + ''' + Return roster users who haven't been scheduled for any event on this team's calendar for this schedule's role. + Ignores events from other teams. + ''' + query = ''' + SELECT DISTINCT `user`.`id` FROM `roster_user` + JOIN `user` ON `user`.`id` = `roster_user`.`user_id` AND `roster_user`.`roster_id` = %s + LEFT JOIN `event` ON `event`.`user_id` = `user`.`id` AND `event`.`team_id` = %s AND `event`.`end` <= %s + AND `event`.`role_id` = %s + WHERE `roster_user`.`in_rotation` = 1 AND `event`.`id` IS NULL + ''' + cursor.execute(query, (roster_id, team_id, start_time, role_id)) + if cursor.rowcount != 0: + logger.debug('Found new guy') + return {row['id'] for row in cursor} + + def create_events(self, team_id, schedule_id, user_id, events, role_id, cursor, skip_match=True): + if len(events) == 0: + return + # Skip creating this epoch of events if matching events exist + if skip_match: + matching = ' OR '.join(['(start = %s AND end = %s AND role_id = %s AND team_id = %s)'] * len(events)) + query_params = [] + + for ev in events: + query_params += [ev['start'], ev['end'], role_id, team_id] + cursor.execute('SELECT COUNT(*) AS num_events FROM event WHERE %s' % matching, query_params) + if cursor.fetchone()['num_events'] == len(events): + return + + if len(events) == 1: + [event] = events + event_args = (team_id, schedule_id, event['start'], event['end'], user_id, role_id) + logger.debug('inserting event: %s', event_args) + query = ''' + INSERT INTO `event` ( + `team_id`, `schedule_id`, `start`, `end`, `user_id`, `role_id` + ) VALUES ( + %s, %s, %s, %s, %s, %s + )''' + cursor.execute(query, event_args) + else: + link_id = gen_link_id() + for event in events: + event_args = (team_id, schedule_id, event['start'], event['end'], user_id, role_id, link_id) + logger.debug('inserting event: %s', event_args) + query = ''' + INSERT INTO `event` ( + `team_id`, `schedule_id`, `start`, `end`, `user_id`, `role_id`, `link_id` + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s + )''' + cursor.execute(query, event_args) + + def set_last_epoch(self, schedule_id, last_epoch, cursor): + cursor.execute('UPDATE `schedule` SET `last_epoch_scheduled` = %s WHERE `id` = %s', + (last_epoch, schedule_id)) + + # End of DB interactions + # Epoch/weekday/time helpers + + def weekday_from_schedule_time(self, schedule_time): + '''Returns 0 for Monday, 1 for Tuesday...''' + return (schedule_time / SECONDS_IN_A_DAY - 1) % 7 + + def epoch_from_datetime(self, dt): + ''' + Given timezoned or naive datetime, returns a naive datetime for 00:00:00 on the + first Sunday before the given date + ''' + sunday = dt + timedelta(days=(-(dt.isoweekday() % 7))) + epoch = sunday.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + return epoch + + def get_closest_epoch(self, dt): + ''' + Given naive datetime, returns naive datetime of the closest epoch (Sunday midnight) + ''' + dt = dt.replace(tzinfo=None) + before_sunday = dt + timedelta(days=(-(dt.isoweekday() % 7))) + before = before_sunday.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + + after_sunday = dt + timedelta(days=7 - dt.isoweekday()) + after = after_sunday.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + + before_diff = dt - before + after_diff = after - dt + if before_diff < after_diff: + return before + else: + return after + + def utc_from_naive_date(self, date, schedule): + tz = timezone(schedule['timezone']) + # Arbitrarily choose ambiguous/nonexistent dates to be in DST. Results in no gaps in a schedule given + # a consistent arbitrary choice. + date = (tz.localize(date, is_dst=1)).astimezone(utc) + td = date - UNIX_EPOCH + # Convert timedelta to seconds + return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10 ** 6) / 10 ** 6 + + # End time helpers + + def generate_events(self, schedule, schedule_events, epoch): + generated = [] + for event in schedule_events: + start = timedelta(seconds=event['start']) + epoch + # Need to calculate naive end date to correct for DST + end = timedelta(seconds=event['start'] + event['duration']) + epoch + start = self.utc_from_naive_date(start, schedule) + end = self.utc_from_naive_date(end, schedule) + generated.append({'start': start, 'end': end}) + return generated + + def get_period_len(self, schedule): + ''' + Find schedule rotation period in weeks, rounded up + ''' + events = schedule['events'] + first_event = min(events, key=operator.itemgetter('start')) + end = max(e['start'] + e['duration'] for e in events) + period = end - first_event['start'] + return ((period + SECONDS_IN_A_WEEK - 1) / SECONDS_IN_A_WEEK) + + def calculate_future_events(self, schedule, cursor, start_epoch=None): + period = self.get_period_len(schedule) + + # DEFINITION: + # epoch: Sunday at 00:00:00 in the schedule's local timezone. This is our point of reference when + # populating events. Why not UTC? DST. + + # Find where to start scheduling + if start_epoch is None: + last_epoch_timestamp = self.get_schedule_last_epoch(schedule, cursor) + + # Handle new schedules, start scheduling from current week + if last_epoch_timestamp is None: + start_dt = datetime.fromtimestamp(time.time(), utc).astimezone(timezone(schedule['timezone'])) + next_epoch = self.epoch_from_datetime(start_dt) + else: + # Otherwise, find the next epoch (NOTE: can't assume that last_epoch_timestamp is Sunday 00:00:00 in the + # schedule's timezone, because the scheduling timezone might have changed. Instead, find the closest + # epoch and work from there) + last_epoch_dt = datetime.fromtimestamp(last_epoch_timestamp, utc) + localized_last_epoch = last_epoch_dt.astimezone(timezone(schedule['timezone'])) + next_epoch = self.get_closest_epoch(localized_last_epoch) + timedelta(days=7 * period) + else: + next_epoch = start_epoch + + cutoff_date = datetime.fromtimestamp(time.time(), utc) + timedelta(days=schedule['auto_populate_threshold']) + cutoff_date = cutoff_date.replace(tzinfo=None) + future_events = [] + # Start scheduling from the next epoch + while cutoff_date > next_epoch: + epoch_events = self.generate_events(schedule, schedule['events'], next_epoch) + next_epoch += timedelta(days=7 * period) + if epoch_events: + future_events.append(epoch_events) + # Return future events and the last epoch events were scheduled for. + return future_events, self.utc_from_naive_date(next_epoch - timedelta(days=7 * period), schedule) + + def find_least_active_available_user_id(self, schedule, future_events, cursor): + team_id = schedule['team_id'] + role_id = schedule['role_id'] + roster_id = schedule['roster_id'] + # find people without conflicting events + # TODO: finer grain conflict checking + user_ids = set(self.get_roster_user_ids(roster_id, cursor)) + if not user_ids: + logger.info('Empty roster, skipping') + return None + logger.debug('filtering users: %s', user_ids) + start = min([e['start'] for e in future_events]) + for uid in self.get_busy_user_by_event_range(user_ids, team_id, future_events, cursor): + user_ids.remove(uid) + if not user_ids: + logger.info('All users have conflicting events, skipping...') + return None + new_user_ids = self.find_new_user_in_roster(roster_id, team_id, start, role_id, cursor) + available_and_new = new_user_ids & user_ids + if available_and_new: + logger.info('Picking new and available user from %s', available_and_new) + return available_and_new.pop() + + logger.debug('picking user between: %s, team: %s', user_ids, team_id) + return self.find_least_active_user_id_by_team(user_ids, team_id, start, role_id, cursor) + + def schedule(self, team, schedules, dbinfo): + connection, cursor = dbinfo + events = [] + for schedule in schedules: + if schedule['auto_populate_threshold'] <= 0: + self.set_last_epoch(schedule['id'], time.time(), cursor) + continue + logger.info('\t\tschedule: %s', str(schedule['id'])) + schedule['timezone'] = team['scheduling_timezone'] + # Calculate events for schedule + future_events, last_epoch = self.calculate_future_events(schedule, cursor) + for epoch in future_events: + # Add (start_time, schedule_id, role_id, roster_id, epoch_events) to events + events.append((schedule, epoch)) + self.set_last_epoch(schedule['id'], last_epoch, cursor) + + # Create events in the db, associating a user to them + # Iterate through events in order of start time to properly assign users + for schedule, epoch in sorted(events, key=lambda x: min(ev['start'] for ev in x[1])): + user_id = self.find_least_active_available_user_id(schedule, epoch, cursor) + if not user_id: + logger.info('Failed to find available user') + continue + logger.info('Found user: %s', user_id) + self.create_events(team['id'], schedule['id'], user_id, epoch, schedule['role_id'], cursor) + connection.commit() + + def populate(self, schedule, start_time, dbinfo): + connection, cursor = dbinfo + start_dt = datetime.fromtimestamp(start_time, utc) + start_epoch = self.epoch_from_datetime(start_dt) + + # Get schedule info + role_id = schedule['role_id'] + team_id = schedule['team_id'] + first_event_start = min(ev['start'] for ev in schedule['events']) + period = self.get_period_len(schedule) + handoff = start_epoch + timedelta(seconds=first_event_start) + handoff = timezone(schedule['timezone']).localize(handoff) + + # Start scheduling from the next occurrence of the hand-off time. + if start_dt > handoff: + start_epoch += timedelta(weeks=period) + handoff += timedelta(weeks=period) + if handoff < utc.localize(datetime.utcnow()): + raise HTTPBadRequest('Invalid populate request', 'cannot populate starting in the past') + + future_events, last_epoch = self.calculate_future_events(schedule, cursor, start_epoch) + self.set_last_epoch(schedule['id'], last_epoch, cursor) + + # Delete existing events from the start of the first event + future_events = [filter(lambda x: x['start'] >= start_time, evs) for evs in future_events] + future_events = filter(lambda x: x != [], future_events) + if future_events: + first_event_start = min(future_events[0], key=lambda x: x['start'])['start'] + cursor.execute('DELETE FROM event WHERE schedule_id = %s AND start >= %s', (schedule['id'], first_event_start)) + + # Create events in the db, associating a user to them + for epoch in future_events: + user_id = self.find_least_active_available_user_id(schedule, epoch, cursor) + if not user_id: + continue + self.create_events(team_id, schedule['id'], user_id, epoch, role_id, cursor) + connection.commit() \ No newline at end of file diff --git a/src/oncall/scheduler/no-skip-matching.py b/src/oncall/scheduler/no-skip-matching.py new file mode 100644 index 0000000..0c56a83 --- /dev/null +++ b/src/oncall/scheduler/no-skip-matching.py @@ -0,0 +1,5 @@ +import default + +class Scheduler(default.Scheduler): + def create_events(self, team_id, schedule_id, user_id, events, role_id, cursor, skip_match=True): + super(Scheduler, self).create_events(team_id, schedule_id, user_id, events, role_id, cursor, skip_match=False) \ No newline at end of file diff --git a/src/oncall/scheduler/round-robin.py b/src/oncall/scheduler/round-robin.py new file mode 100644 index 0000000..ca86a9d --- /dev/null +++ b/src/oncall/scheduler/round-robin.py @@ -0,0 +1,70 @@ +from oncall.utils import gen_link_id +import default +import logging + +logger = logging.getLogger() + +class Scheduler(default.Scheduler): + + def guess_last_scheduled_user(self, schedule, start, roster, cursor): + cursor.execute(''' + SELECT MAX(`last_end`), `user_id` FROM + (SELECT `user_id`, MAX(`end`) AS `last_end` FROM `event` + WHERE `team_id` = %s AND `user_id` IN %s AND `end` <= %s + AND `role_id` = %s + GROUP BY `user_id`) t + GROUP BY `user_id` + ''', (schedule['team_id'], roster, start, schedule['role_id'])) + if cursor.rowcount != 0: + return cursor.fetchone()['user_id'] + else: + return None + + def find_least_active_available_user_id(self, schedule, future_events, cursor): + # Ordered by roster priority, break potential ties with user id + cursor.execute('''SELECT `roster_id`, `user_id`, `roster_priority` + FROM roster_user WHERE roster_id = %s AND `in_rotation` = 1 + ORDER BY roster_priority, user_id''', + schedule['roster_id']) + roster = [row['user_id'] for row in cursor] + cursor.execute('SELECT last_scheduled_user_id FROM schedule WHERE id = %s', schedule['id']) + if cursor.rowcount == 0 or roster == []: + # Schedule doesn't exist, or roster is empty. Bail + return None + last_user = cursor.fetchone()['last_scheduled_user_id'] + if last_user not in roster: + # If this user is no longer in the roster or last_scheduled_user in NULL, try to find + # the last scheduled user using the calendar + start = min(e['start'] for e in future_events) + last_user = self.guess_last_scheduled_user(schedule, start, roster, cursor) + if last_user is None: + # If this doesn't work, return the first user in the roster + return roster[0] + last_idx = roster.index(last_user) + return roster[(last_idx + 1) % len(roster)] + + def create_events(self, team_id, schedule_id, user_id, events, role_id, cursor): + if len(events) == 1: + [event] = events + event_args = (team_id, schedule_id, event['start'], event['end'], user_id, role_id) + logger.debug('inserting event: %s', event_args) + query = ''' + INSERT INTO `event` ( + `team_id`, `schedule_id`, `start`, `end`, `user_id`, `role_id` + ) VALUES ( + %s, %s, %s, %s, %s, %s + )''' + cursor.execute(query, event_args) + else: + link_id = gen_link_id() + for event in events: + event_args = (team_id, schedule_id, event['start'], event['end'], user_id, role_id, link_id) + logger.debug('inserting event: %s', event_args) + query = ''' + INSERT INTO `event` ( + `team_id`, `schedule_id`, `start`, `end`, `user_id`, `role_id`, `link_id` + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s + )''' + cursor.execute(query, event_args) + cursor.execute('UPDATE `schedule` SET `last_scheduled_user_id` = %s', user_id) diff --git a/test/test_scheduler.py b/test/test_scheduler.py index dc04157..972378f 100644 --- a/test/test_scheduler.py +++ b/test/test_scheduler.py @@ -4,7 +4,7 @@ import datetime import time import calendar -from oncall.bin import scheduler +import oncall.scheduler.default from pytz import utc, timezone MIN = 60 @@ -12,19 +12,21 @@ HOUR = 60 * MIN DAY = 24 * HOUR WEEK = 7 * DAY +MOCK_SCHEDULE = {'team_id': 1, 'role_id': 2, 'roster_id': 3} def test_find_new_user_as_least_active_user(mocker): - mocker.patch('oncall.bin.scheduler.find_new_user_in_roster').return_value = set([123]) - mocker.patch('oncall.bin.scheduler.get_roster_user_ids').return_value = set([135, 123]) - mocker.patch('oncall.bin.scheduler.get_busy_user_by_event_range') - mocker.patch('oncall.bin.scheduler.find_least_active_user_id_by_team') + scheduler = oncall.scheduler.default.Scheduler() + mocker.patch('oncall.scheduler.default.Scheduler.find_new_user_in_roster').return_value = {123} + mocker.patch('oncall.scheduler.default.Scheduler.get_roster_user_ids').return_value = {135, 123} + mocker.patch('oncall.scheduler.default.Scheduler.get_busy_user_by_event_range') + mocker.patch('oncall.scheduler.default.Scheduler.find_least_active_user_id_by_team') - user_id = scheduler.find_least_active_available_user_id(1, 1, 1, [{'start': 0, 'end': 5}], None) + user_id = scheduler.find_least_active_available_user_id(MOCK_SCHEDULE, [{'start': 0, 'end': 5}], None) assert user_id == 123 def test_calculate_future_events_7_24_shifts(mocker): - mocker.patch('oncall.bin.scheduler.get_schedule_last_epoch').return_value = None + mocker.patch('oncall.scheduler.default.Scheduler.get_schedule_last_epoch').return_value = None mock_dt = datetime.datetime(year=2017, month=2, day=7, hour=10) mocker.patch('time.time').return_value = time.mktime(mock_dt.timetuple()) start = DAY + 10 * HOUR + 30 * MIN # Monday at 10:30 am @@ -36,6 +38,7 @@ def test_calculate_future_events_7_24_shifts(mocker): 'duration': WEEK }] } + scheduler = oncall.scheduler.default.Scheduler() future_events, last_epoch = scheduler.calculate_future_events(schedule_foo, None) assert len(future_events) == 4 @@ -56,7 +59,7 @@ def test_calculate_future_events_7_24_shifts(mocker): def test_calculate_future_events_7_12_shifts(mocker): - mocker.patch('oncall.bin.scheduler.get_schedule_last_epoch').return_value = None + mocker.patch('oncall.scheduler.default.Scheduler.get_schedule_last_epoch').return_value = None mock_dt = datetime.datetime(year=2016, month=9, day=9, hour=10) mocker.patch('time.time').return_value = time.mktime(mock_dt.timetuple()) start = 3 * DAY + 12 * HOUR # Wednesday at noon @@ -68,6 +71,7 @@ def test_calculate_future_events_7_12_shifts(mocker): 'auto_populate_threshold': 7, 'events': events } + scheduler = oncall.scheduler.default.Scheduler() future_events, last_epoch = scheduler.calculate_future_events(schedule_foo, None) assert len(future_events) == 2 assert len(future_events[0]) == 7 @@ -85,7 +89,7 @@ def test_calculate_future_events_7_12_shifts(mocker): def test_calculate_future_events_14_12_shifts(mocker): - mocker.patch('oncall.bin.scheduler.get_schedule_last_epoch').return_value = None + mocker.patch('oncall.scheduler.default.Scheduler.get_schedule_last_epoch').return_value = None mock_dt = datetime.datetime(year=2016, month=9, day=9, hour=10) mocker.patch('time.time').return_value = time.mktime(mock_dt.timetuple()) start = 3 * DAY + 12 * HOUR # Wednesday at noon @@ -97,6 +101,7 @@ def test_calculate_future_events_14_12_shifts(mocker): 'auto_populate_threshold': 21, 'events': events } + scheduler = oncall.scheduler.default.Scheduler() future_events, last_epoch = scheduler.calculate_future_events(schedule_foo, None) assert len(future_events) == 2 assert len(future_events[1]) == 14 @@ -112,7 +117,7 @@ def test_calculate_future_events_14_12_shifts(mocker): def test_dst_ambiguous_schedule(mocker): - mocker.patch('oncall.bin.scheduler.get_schedule_last_epoch').return_value = None + mocker.patch('oncall.scheduler.default.Scheduler.get_schedule_last_epoch').return_value = None mock_dt = datetime.datetime(year=2016, month=10, day=29, hour=10) mocker.patch('time.time').return_value = time.mktime(mock_dt.timetuple()) start = HOUR + 30 * MIN # Sunday at 1:30 am @@ -124,6 +129,7 @@ def test_dst_ambiguous_schedule(mocker): 'duration': WEEK }] } + scheduler = oncall.scheduler.default.Scheduler() future_events, last_epoch = scheduler.calculate_future_events(schedule_foo, None) assert len(future_events) == 3 @@ -134,7 +140,7 @@ def test_dst_ambiguous_schedule(mocker): def test_dst_schedule(mocker): - mocker.patch('oncall.bin.scheduler.get_schedule_last_epoch').return_value = None + mocker.patch('oncall.scheduler.default.Scheduler.get_schedule_last_epoch').return_value = None mock_dt = datetime.datetime(year=2016, month=10, day=29, hour=10) mocker.patch('time.time').return_value = time.mktime(mock_dt.timetuple()) start = DAY + 11 * HOUR # Monday at 11:00 am @@ -146,6 +152,7 @@ def test_dst_schedule(mocker): 'duration': WEEK }] } + scheduler = oncall.scheduler.default.Scheduler() future_events, last_epoch = scheduler.calculate_future_events(schedule_foo, None) assert len(future_events) == 3 @@ -161,7 +168,7 @@ def test_dst_schedule(mocker): def test_existing_schedule(mocker): mock_dt = datetime.datetime(year=2017, month=2, day=5, hour=0, tzinfo=timezone('US/Pacific')) - mocker.patch('oncall.bin.scheduler.get_schedule_last_epoch').return_value = \ + mocker.patch('oncall.scheduler.default.Scheduler.get_schedule_last_epoch').return_value = \ calendar.timegm(mock_dt.astimezone(utc).timetuple()) mocker.patch('time.time').return_value = time.mktime(datetime.datetime(year=2017, month=2, day=7).timetuple()) start = DAY + 10 * HOUR + 30 * MIN # Monday at 10:30 am @@ -173,6 +180,7 @@ def test_existing_schedule(mocker): 'duration': WEEK }] } + scheduler = oncall.scheduler.default.Scheduler() future_events, last_epoch = scheduler.calculate_future_events(schedule_foo, None) assert len(future_events) == 3 @@ -194,7 +202,7 @@ def test_existing_schedule(mocker): def test_existing_schedule_change_epoch(mocker): mock_dt = datetime.datetime(year=2017, month=2, day=5, hour=0, tzinfo=timezone('US/Eastern')) - mocker.patch('oncall.bin.scheduler.get_schedule_last_epoch').return_value = \ + mocker.patch('oncall.scheduler.default.Scheduler.get_schedule_last_epoch').return_value = \ calendar.timegm(mock_dt.astimezone(utc).timetuple()) mocker.patch('time.time').return_value = time.mktime(datetime.datetime(year=2017, month=2, day=7).timetuple()) start = DAY + 10 * HOUR + 30 * MIN # Monday at 10:30 am @@ -206,6 +214,7 @@ def test_existing_schedule_change_epoch(mocker): 'duration': WEEK }] } + scheduler = oncall.scheduler.default.Scheduler() future_events, last_epoch = scheduler.calculate_future_events(schedule_foo, None) assert len(future_events) == 3 @@ -227,10 +236,10 @@ def test_existing_schedule_change_epoch(mocker): def test_find_least_active_available_user(mocker): mock_user_ids = [123, 456, 789] - mocker.patch('oncall.bin.scheduler.find_new_user_in_roster').return_value = set() - mocker.patch('oncall.bin.scheduler.get_roster_user_ids').return_value = [i for i in mock_user_ids] - mock_busy_user_by_range = mocker.patch('oncall.bin.scheduler.get_busy_user_by_event_range') - mock_active_user_by_team = mocker.patch('oncall.bin.scheduler.find_least_active_user_id_by_team') + mocker.patch('oncall.scheduler.default.Scheduler.find_new_user_in_roster').return_value = set() + mocker.patch('oncall.scheduler.default.Scheduler.get_roster_user_ids').return_value = [i for i in mock_user_ids] + mock_busy_user_by_range = mocker.patch('oncall.scheduler.default.Scheduler.get_busy_user_by_event_range') + mock_active_user_by_team = mocker.patch('oncall.scheduler.default.Scheduler.find_least_active_user_id_by_team') def mock_busy_user_by_range_side_effect(user_ids, team_id, events, cursor): assert user_ids == set(mock_user_ids) @@ -240,17 +249,18 @@ def test_find_least_active_available_user(mocker): future_events = [{'start': 440, 'end': 570}, {'start': 570, 'end': 588}, {'start': 600, 'end': 700}] - scheduler.find_least_active_available_user_id(1, 2, 3, future_events, None) + scheduler = oncall.scheduler.default.Scheduler() + scheduler.find_least_active_available_user_id(MOCK_SCHEDULE, future_events, None) - mock_active_user_by_team.assert_called_with(set([456, 789]), 1, 440, 2, None) + mock_active_user_by_team.assert_called_with({456, 789}, 1, 440, 2, None) def test_find_least_active_available_user_conflicts(mocker): mock_user_ids = [123, 456, 789] - mocker.patch('oncall.bin.scheduler.find_new_user_in_roster').return_value = None - mocker.patch('oncall.bin.scheduler.get_roster_user_ids').return_value = [i for i in mock_user_ids] - mock_busy_user_by_range = mocker.patch('oncall.bin.scheduler.get_busy_user_by_event_range') - mock_active_user_by_team = mocker.patch('oncall.bin.scheduler.find_least_active_user_id_by_team') + mocker.patch('oncall.scheduler.default.Scheduler.find_new_user_in_roster').return_value = None + mocker.patch('oncall.scheduler.default.Scheduler.get_roster_user_ids').return_value = [i for i in mock_user_ids] + mock_busy_user_by_range = mocker.patch('oncall.scheduler.default.Scheduler.get_busy_user_by_event_range') + mock_active_user_by_team = mocker.patch('oncall.scheduler.default.Scheduler.find_least_active_user_id_by_team') def mock_busy_user_by_range_side_effect(user_ids, team_id, events, cursor): assert user_ids == set(mock_user_ids) @@ -258,6 +268,7 @@ def test_find_least_active_available_user_conflicts(mocker): mock_busy_user_by_range.side_effect = mock_busy_user_by_range_side_effect future_events = [{'start': 440, 'end': 570}] - assert scheduler.find_least_active_available_user_id(1, 2, 3, future_events, None) is None + scheduler = oncall.scheduler.default.Scheduler() + assert scheduler.find_least_active_available_user_id(MOCK_SCHEDULE, future_events, None) is None mock_active_user_by_team.assert_not_called()