You've already forked oncall
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:
committed by
Joe Gillotti
parent
7f44029085
commit
1ea7fbb8ea
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
-- -----------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
0
src/oncall/scheduler/__init__.py
Normal file
0
src/oncall/scheduler/__init__.py
Normal file
334
src/oncall/scheduler/default.py
Normal file
334
src/oncall/scheduler/default.py
Normal 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()
|
||||
5
src/oncall/scheduler/no-skip-matching.py
Normal file
5
src/oncall/scheduler/no-skip-matching.py
Normal 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)
|
||||
70
src/oncall/scheduler/round-robin.py
Normal file
70
src/oncall/scheduler/round-robin.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user