From ff0f83fb6b3cd132eba9ef9d4bc8a492e7d0fe20 Mon Sep 17 00:00:00 2001 From: Daniel Wang Date: Mon, 26 Feb 2018 12:23:47 -0800 Subject: [PATCH] Add UI page for team subscriptions Also, get user data if not available in calendar events --- e2e/test_subscription.py | 6 +- src/oncall/api/v0/team_subscriptions.py | 19 +- src/oncall/ui/static/css/oncall.css | 69 ++++++++ src/oncall/ui/static/js/oncall.js | 223 +++++++++++++++++++++--- src/oncall/ui/templates/index.html | 84 +++++++++ 5 files changed, 362 insertions(+), 39 deletions(-) diff --git a/e2e/test_subscription.py b/e2e/test_subscription.py index 3ea90cd..dc8d589 100644 --- a/e2e/test_subscription.py +++ b/e2e/test_subscription.py @@ -18,8 +18,8 @@ def test_api_v0_team_subscription(team, role): re = requests.get(api_v0('teams/%s/subscriptions' % team_name)) assert re.status_code == 200 data = re.json() - assert team_name_2 in data - assert team_name_3 in data + assert {'role': role_name, 'subscription': team_name_2} in data + assert {'role': role_name, 'subscription': team_name_3} in data assert len(data) == 2 re = requests.delete(api_v0('teams/%s/subscriptions/%s/%s' % (team_name, team_name_3, role_name))) @@ -28,7 +28,7 @@ def test_api_v0_team_subscription(team, role): re = requests.get(api_v0('teams/%s/subscriptions' % team_name)) assert re.status_code == 200 data = re.json() - assert team_name_2 in data + assert {'role': role_name, 'subscription': team_name_2} in data assert len(data) == 1 diff --git a/src/oncall/api/v0/team_subscriptions.py b/src/oncall/api/v0/team_subscriptions.py index a9b409c..4162929 100644 --- a/src/oncall/api/v0/team_subscriptions.py +++ b/src/oncall/api/v0/team_subscriptions.py @@ -10,14 +10,14 @@ logger = logging.getLogger('oncall-api') def on_get(req, resp, team): connection = db.connect() - cursor = connection.cursor() - cursor.execute('''SELECT `subscription`.`name`, `role`.`name` FROM `team` + cursor = connection.cursor(db.DictCursor) + cursor.execute('''SELECT `subscription`.`name` AS `subscription`, `role`.`name` AS `role` FROM `team` JOIN `team_subscription` ON `team`.`id` = `team_subscription`.`team_id` JOIN `team` `subscription` ON `subscription`.`id` = `team_subscription`.`subscription_id` JOIN `role` ON `role`.`id` = `team_subscription`.`role_id` WHERE `team`.`name` = %s''', team) - data = [row[0] for row in cursor] + data = [row for row in cursor] cursor.close() connection.close() resp.body = json_dumps(data) @@ -31,6 +31,8 @@ def on_post(req, resp, team): role_name = data.get('role') if not sub_name or not role_name: raise HTTPBadRequest('Invalid subscription', 'Missing subscription name or role name') + if sub_name == team: + raise HTTPBadRequest('Invalid subscription', 'Subscription team must be different from subscribing team') connection = db.connect() cursor = connection.cursor() try: @@ -42,12 +44,15 @@ def on_post(req, resp, team): except db.IntegrityError as e: err_msg = str(e.args[1]) if err_msg == 'Column \'team_id\' cannot be null': - err_msg = 'team "%s" not found' % team + err_msg = 'Team "%s" not found' % team elif err_msg == 'Column \'role_id\' cannot be null': - err_msg = 'role "%s" not found' % role_name + err_msg = 'Role "%s" not found' % role_name elif err_msg == 'Column \'subscription_id\' cannot be null': - err_msg = 'team "%s" not found' % sub_name - logger.exception('Unknown integrity error in team_subscriptions') + err_msg = 'Team "%s" not found' % sub_name + elif err_msg.startswith('Duplicate entry'): + err_msg = 'Subscription already exists' + else: + logger.exception('Unknown integrity error in team_subscriptions') raise HTTPError('422 Unprocessable Entity', 'IntegrityError', err_msg) else: connection.commit() diff --git a/src/oncall/ui/static/css/oncall.css b/src/oncall/ui/static/css/oncall.css index bb3835e..0d4cf14 100644 --- a/src/oncall/ui/static/css/oncall.css +++ b/src/oncall/ui/static/css/oncall.css @@ -1237,6 +1237,75 @@ overflow: hidden; min-width: 95px; } +/* + * Team subscription page + */ + +.subscription-actions { + padding-top: 10px; +} + +.module-subscription { + width: 24%; + margin: 0 .5%; + float: left; + transition: box-shadow .15s; +} + +.module-subscription .subscription-actions { + border-top: 5px solid #FDE3D2; + padding-top: 10px; + margin-top: 10px; +} + +.module-subscription .subscription-actions > span { + cursor: pointer; + color: #000; + opacity: .3; + font-weight: bold; +} + +.module-subscription .subscription-actions > span .grey-icon { + opacity: 1; +} + +.module-subscription .subscription-actions > span:hover { + opacity: .5; +} + +.module-subscription .label-col { + width: 27%; + max-width: 110px; + vertical-align: top; +} + +.module-subscription .data-col { + text-transform: capitalize; + display: inline-block; +} + +.module-subscription .data-col.subscription-role { + display: inline-block; + width: 167px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.module-subscription .subscription-actions { + border-top: 5px solid #CCC; + padding-top: 10px; + margin-top: 10px; +} + +.module-subscription-create .form-control, +#subscription-team-container { + width: 150px; + display: inline-block; + margin: 5px; + vertical-align: middle !important; +} + /* * User notifications page */ diff --git a/src/oncall/ui/static/js/oncall.js b/src/oncall/ui/static/js/oncall.js index b24e748..9779a1b 100644 --- a/src/oncall/ui/static/js/oncall.js +++ b/src/oncall/ui/static/js/oncall.js @@ -225,6 +225,12 @@ var oncall = { self.team.init(params.name, 'schedules'); self.team.schedules.init(params.name); }, + 'team/:name/subscriptions': function(params){ + oncall.callbacks.onLogin = $.noop; + oncall.callbacks.onLogout = $.noop; + self.team.init(params.name, 'subscriptions'); + self.team.subscriptions.init(params.name); + }, 'team/:name': function(params){ oncall.callbacks.onLogin = $.noop; oncall.callbacks.onLogout = $.noop; @@ -850,6 +856,7 @@ var oncall = { calendar: '#calendar-container', $calendar: null, url: '/api/v0/teams/', + userUrl: '/api/v0/users/', pageSource: $('#team-calendar-template').html(), escalateModalTemplate: $('#team-escalate-modal'), cardColumnTemplate: $('#card-column-template').html(), @@ -1072,36 +1079,62 @@ var oncall = { // #TODO: Leverage this to create whole modal var $ul = $modal.find('.inc-event-details-view'), $title = $modal.find('.inc-event-details-title'), - userData = this.data.teamData.users[evt.user]; + userData = this.data.teamData.users[evt.user], + userPromise = $.Deferred(), + self = this; - $title.text(userData.full_name); - $ul - .append( - $('
  • ') - .append('') - .append('' + userData.contacts.email + '') - ) - .append( - $('
  • ') - .append('') - .append('' + userData.contacts.call + '') - ) - .append( - $('
  • ') - .append('') - .append('' + userData.contacts.sms + '') - ) - .append( - $('
  • ') - .append('') - .append('' + userData.contacts.slack + '') - ); - if (evt.schedule_id) { - $ul.append( - $('
  • ') - .append('This event is auto generated by the scheduler') - ); + if (userData !== undefined) { + userPromise.resolve(); + } else { + $.ajax({ + type: 'GET', + url: this.data.userUrl + evt.user, + contentType: 'application/json', + dataType: 'html' + }).done(function(response){ + userData = JSON.parse(response); + self.data.teamData.users[evt.user] = userData; + userPromise.resolve(); + }).fail(function(data){ + userPromise.reject(); + }) } + userPromise.done(function() { + $title.text(userData.full_name); + $ul + .append( + $('
  • ') + .append('') + .append('' + userData.contacts.email + '') + ) + .append( + $('
  • ') + .append('') + .append('' + userData.contacts.call + '') + ) + .append( + $('
  • ') + .append('') + .append('' + userData.contacts.sms + '') + ) + .append( + $('
  • ') + .append('') + .append('' + userData.contacts.slack + '') + ); + if (evt.schedule_id) { + $ul.append( + $('
  • ') + .append('This event is auto generated by the scheduler') + ); + } + if (evt.team !== self.data.teamName) { + $ul.append( + $('
  • ') + .append('This is a subscription event from ' + evt.team + '') + ); + } + }); }, updatePlanDescription: function() { var $modal = $(this.data.escalateModal), @@ -1469,6 +1502,138 @@ var oncall = { }); } }, + subscriptions: { + data: { + $page: $('.content-wrapper'), + url: '/api/v0/teams/', + subscriptionUrl: '/subscriptions', + teamName: null, + pageSource: $('#team-subscriptions-template').html(), + moduleSubscriptionTemplate: $('#module-subscription-template').html(), + moduleSubscriptionCreateTemplate: $('#module-subscription-create-template').html(), + addSubscriptionItem: '#add-subscription', + saveSubscription: '#save-subscription', + addSubscriptionContainer: '.add-subscription-container', + subscriptionCreateForm: '.module-subscription-create', + moduleSubscriptionsWrapper: '.module-subscriptions-wrapper', + deleteSubscriptionCard: '.delete-subscription-item', + subscriptionItem: '.module-subscription', + subscriptionCount: '.subscription-count' + }, + init: function(name){ + Handlebars.registerPartial('module-subscription', this.data.moduleSubscriptionTemplate); + Handlebars.registerPartial('module-subscription-create', this.data.moduleSubscriptionCreateTemplate); + this.data.teamName = decodeURIComponent(name); + this.getData(); + }, + events: function(){ + router.updatePageLinks(); + this.data.$page.on('click', this.data.addSubscriptionItem, this.addSubscriptionItem.bind(this)); + this.data.$page.on('submit', this.data.subscriptionCreateForm, this.saveSubscription.bind(this)); + this.data.$page.on('click', this.data.deleteSubscriptionCard, this.deleteSubscription.bind(this)); + }, + getData: function(){ + var template = Handlebars.compile(this.data.pageSource), + self = this; + + $.when($.getJSON(this.data.url + this.data.teamName + this.data.subscriptionUrl), + $.getJSON(this.data.url + this.data.teamName), + oncall.data.rolesPromise).done(function(subData, teamData){ + data = teamData[0]; + data.subscriptions = subData[0]; + data.roles = oncall.data.roles; + + self.data.teamData = data; + self.data.$page.html(template(data)); + self.events(); + self.renderSubscriptionCounts(); + }).fail(function(error){ + var data = { + error: true, + error_code: error.status, + error_status: error.statusText, + error_text: name + ' team not found' + }; + self.data.$page.html(template(data)); + }); + }, + addSubscriptionItem: function(e){ + var template = Handlebars.compile(this.data.moduleSubscriptionCreateTemplate), + $container = $(e.target).parents().find(this.data.addSubscriptionContainer), + teamData = $.extend(true, {}, this.data.teamData); + $container.prepend(template(teamData)); + oncall.typeahead.init(); + }, + saveSubscription: function(e){ + e.preventDefault(); + + var self = this, + $form = $(e.target), + subscription = {}, + $cta = $form.find(this.data.saveSubscription), + template = Handlebars.compile(this.data.moduleSubscriptionTemplate), + url = this.data.url + this.data.teamName + this.data.subscriptionUrl, + method = 'POST'; + + subscription.subscription = $form.find('input.typeahead.tt-input.subscription-team').val(); + subscription.role = $form.find('.subscription-role').val(); + if (subscription.role === undefined || subscription.subscription === '') { + oncall.alerts.createAlert('Invalid or missing field.'); + } else { + oncall.alerts.removeAlerts(); + $.ajax({ + type: method, + url: url, + contentType: 'application/json', + dataType: 'html', + data: JSON.stringify(subscription) + }).done(function () { + self.data.$page.find(self.data.moduleSubscriptionsWrapper).append(template(subscription)); + $form.remove(); + self.renderSubscriptionCounts(); + }).fail(function (data) { + var error = oncall.isJson(data.responseText) ? JSON.parse(data.responseText).description : data.responseText || 'Request failed.'; + oncall.alerts.createAlert(error, 'danger'); + }).always(function () { + $cta.removeClass('loading disabled').prop('disabled', false); + }); + } + }, + deleteSubscriptionItem: function($modal, $caller){ + var $form = $caller.parents(this.data.subscriptionCreateForm); + $form.remove(); + $modal.modal('hide'); + }, + deleteSubscription: function($modal, $caller) { + var $card = $caller.parents('.module-card'), + $modalBody = $modal.find('.modal-body'), + $cta = $modal.find('.modal-cta'), + role = $card.attr('data-role'), + subscription = $card.attr('data-team'), + url = this.data.url + this.data.teamName + '/subscriptions/' + subscription + '/' + role, + self = this; + + $cta.addClass('loading disabled').prop('disabled', true); + + $.ajax({ + type: 'DELETE', + url: url, + dataType: 'html' + }).done(function(){ + $modal.modal('hide'); + $card.remove(); + self.renderSubscriptionCounts(); + }).fail(function(data){ + var error = oncall.isJson(data.responseText) ? JSON.parse(data.responseText).description : data.responseText || 'Delete failed.'; + oncall.alerts.createAlert(error, 'danger', $modalBody); + }).always(function(){ + $cta.removeClass('loading disabled').prop('disabled', false); + }); + }, + renderSubscriptionCounts: function() { + $(this.data.subscriptionCount).text($(this.data.moduleSubscriptionsWrapper).find(this.data.subscriptionItem).length); + } + }, schedules: { data: { $page: $('.content-wrapper'), @@ -2671,7 +2836,7 @@ var oncall = { type = $this.attr('data-type') || 'users', results; - if (type === 'services') { + if (type === 'services' || type == 'teams') { results = new Bloodhound({ datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'), queryTokenizer: Bloodhound.tokenizers.whitespace, diff --git a/src/oncall/ui/templates/index.html b/src/oncall/ui/templates/index.html index 5d6c66c..2c4eb92 100644 --- a/src/oncall/ui/templates/index.html +++ b/src/oncall/ui/templates/index.html @@ -322,6 +322,7 @@
  • Calendar
  • Team Info
  • Schedule Templates
  • +
  • Subscriptions
  • {{#isEqual page "calendar"}} Display Timezone: @@ -603,6 +604,89 @@ + + + + + + + +