1
0
mirror of https://github.com/linkedin/oncall.git synced 2025-11-26 23:10:47 +02:00

Pluggable schedulers, round robin scheduling, roster order

This commit is contained in:
Daniel Wang
2017-10-26 17:47:23 -07:00
committed by Joe Gillotti
parent 7f44029085
commit 1ea7fbb8ea
14 changed files with 709 additions and 388 deletions

View File

@@ -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;

View File

@@ -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
-- -----------------------------------------------------

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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:

View File

@@ -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)

View File

@@ -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()

View File

@@ -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:

View File

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()