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

Add UI page for team subscriptions

Also, get user data if not available in calendar events
This commit is contained in:
Daniel Wang
2018-02-26 12:23:47 -08:00
parent c10f908f37
commit ff0f83fb6b
5 changed files with 362 additions and 39 deletions

View File

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

View File

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

View File

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

View File

@@ -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(
$('<li />')
.append('<label class="label-col">E-Mail</label>')
.append('<span class="data-col"><a href="mailto:' + userData.contacts.email + '" target="_blank">' + userData.contacts.email + '</a></span>')
)
.append(
$('<li />')
.append('<label class="label-col">Call</label>')
.append('<span class="data-col"><a href="tel:' + userData.contacts.call + '">' + userData.contacts.call + '</a></span>')
)
.append(
$('<li />')
.append('<label class="label-col">SMS</label>')
.append('<span class="data-col"><a href="tel:' + userData.contacts.sms + '">' + userData.contacts.sms + '</a></span>')
)
.append(
$('<li />')
.append('<label class="label-col">Slack</label>')
.append('<span class="data-col">' + userData.contacts.slack + '</span>')
);
if (evt.schedule_id) {
$ul.append(
$('<li />')
.append('<small>This event is auto generated by the scheduler</small>')
);
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(
$('<li />')
.append('<label class="label-col">E-Mail</label>')
.append('<span class="data-col"><a href="mailto:' + userData.contacts.email + '" target="_blank">' + userData.contacts.email + '</a></span>')
)
.append(
$('<li />')
.append('<label class="label-col">Call</label>')
.append('<span class="data-col"><a href="tel:' + userData.contacts.call + '">' + userData.contacts.call + '</a></span>')
)
.append(
$('<li />')
.append('<label class="label-col">SMS</label>')
.append('<span class="data-col"><a href="tel:' + userData.contacts.sms + '">' + userData.contacts.sms + '</a></span>')
)
.append(
$('<li />')
.append('<label class="label-col">Slack</label>')
.append('<span class="data-col">' + userData.contacts.slack + '</span>')
);
if (evt.schedule_id) {
$ul.append(
$('<li />')
.append('<small>This event is auto generated by the scheduler</small>')
);
}
if (evt.team !== self.data.teamName) {
$ul.append(
$('<li />')
.append('<small>This is a subscription event from ' + evt.team + '</small>')
);
}
});
},
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,

View File

@@ -322,6 +322,7 @@
<li {{#isEqual page "calendar"}}class="active"{{/isEqual}} href="/team/{{name}}" data-navigo> Calendar </li>
<li {{#isEqual page "info"}}class="active"{{/isEqual}} href="/team/{{name}}/info" data-navigo> Team Info </li>
<li data-admin-action="true" {{#isEqual page "schedules"}}class="active"{{/isEqual}} href="/team/{{name}}/schedules" data-navigo> Schedule Templates </li>
<li data-admin-action="true" {{#isEqual page "subscriptions"}}class="active"{{/isEqual}} href="/team/{{name}}/subscriptions" data-navigo> Subscriptions </li>
{{#isEqual page "calendar"}}
<span class="timezone-display-container pull-right">
Display Timezone:
@@ -603,6 +604,89 @@
</div>
</script>
<!--// **********************
Team.subscriptions Page
*********************** //-->
<script id="team-subscriptions-template" type="text/x-handlebars-template">
{{#if error}}
{{>error-page this}}
{{else}}
<div class="subheader-wrapper">
{{>team-subheader name=name page="subscriptions"}}
</div>
<div class="main container-fluid">
<h3 class="module-heading"> Subscriptions <button id="add-subscription" class="btn btn-primary pull-right">+ New Subscription</button></h3>
<div class="add-subscription-container">
<!--// create form renders here -->
</div>
<div class="module module-subscriptions-container">
<h4 class="module-heading border-bottom"> You have <span class="subscription-count"> 0 </span> subscriptions </h4>
<div class="module-subscriptions-wrapper clearfix" data-type="subscription">
{{#each this.subscriptions}}
{{>module-subscription viewType="schedule-item"}}
{{/each}}
</div>
</div>
</div>
{{/if}}
</script>
<script id="module-subscription-template" type="text/x-handlebars-template">
<div class="module module-card module-subscription" data-role="{{role}}" data-team="{{subscription}}">
<ul class="subscription-details">
<li>
<label class="light label-col">Team:</label>
<span class="data-col">{{subscription}}</span>
</li>
<li>
<label class="light label-col">Role:</label>
<span class="data-col">{{role}} </span>
</li>
</ul>
<div class="subscription-actions border-top">
<span class="delete-{{viewType}}" data-toggle="modal" data-target="#confirm-action-modal" data-modal-action="oncall.team.subscriptions.deleteSubscription" data-modal-title="Delete subscription" data-modal-content="Delete subscription to {{role}} events from {{subscription}}?">
<i class="svg-icon svg-icon-trash grey-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="14px" height="14px" viewBox="0 0 8 8">
<path d="M3 0c-.55 0-1 .45-1 1h-1c-.55 0-1 .45-1 1h7c0-.55-.45-1-1-1h-1c0-.55-.45-1-1-1h-1zm-2 3v4.81c0 .11.08.19.19.19h4.63c.11 0 .19-.08.19-.19v-4.81h-1v3.5c0 .28-.22.5-.5.5s-.5-.22-.5-.5v-3.5h-1v3.5c0 .28-.22.5-.5.5s-.5-.22-.5-.5v-3.5h-1z" />
</svg>
</i>
Delete
</span>
</div>
</div>
</script>
<!-- subscription create partial -->
<script id="module-subscription-create-template" type="text/x-handlebars-template">
<form class="module module-card module-subscription-create" >
<div class="module-heading module-card-heading border-bottom">
<h4>
Create a new subscription
</h4>
</div>
<div class="subscription-create-body">
Add
<select name="from" class="form-control subscription-role">
{{#each roles}}
<option value="{{this.name}}" {{isSelected ../selected_subscription.role this.name}}>
{{this.name}}
</option>
{{/each}}
</select>
events from the
<span id="subscription-team-container">
<input type="text" class="form-control typeahead subscription-team" placeholder="Team" data-type="teams" />
</span>
calendar to this team's calendar.
<div class="border-top subscription-actions clearfix">
<div class="pull-right">
<button id="save-subscription" class="btn btn-primary" type="submit"><span class="btn-text">Save</span> <i class="loader loader-small"></i> </button>
<button class="btn btn-blue delete-subscription" type="button" data-toggle="modal" data-target="#confirm-action-modal" data-modal-action="oncall.team.subscriptions.deleteSubscriptionItem" data-modal-title="Your changes are unsaved" data-modal-content="Aborting now will lose unsaved changes."> Cancel </button>
</div>
</div>
</form>
</script>
<!--// **********************
Team.schedules Page
*********************** //-->