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

Add team pinning

Allow users to pin teams to landing page. Persist data in db to
keep view consistent across machines
This commit is contained in:
Daniel Wang
2017-06-02 10:56:20 -07:00
committed by Qingping Hou
parent 6fb39e2105
commit 05e815a7a7
9 changed files with 439 additions and 117 deletions

View File

@@ -29,6 +29,26 @@ CREATE TABLE IF NOT EXISTS `user` (
PRIMARY KEY (`id`),
UNIQUE INDEX `username_unique` (`name` ASC));
-- -----------------------------------------------------
-- Table `pinned_team`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `pinned_team` (
`team_id` BIGINT(20) UNSIGNED NOT NULL,
`user_id` BIGINT(20) UNSIGNED NOT NULL,
INDEX `team_member_team_id_idx` (`team_id` ASC),
INDEX `team_member_user_id_idx` (`user_id` ASC),
PRIMARY KEY (`team_id`, `user_id`),
CONSTRAINT `pinned_team_team_id_fk`
FOREIGN KEY (`team_id`)
REFERENCES `team` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `pinned_team_user_id_fk`
FOREIGN KEY (`user_id`)
REFERENCES `user` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE);
-- -----------------------------------------------------
-- Table `team_user`
-- -----------------------------------------------------

65
e2e/test_pin.py Normal file
View File

@@ -0,0 +1,65 @@
import requests
from testutils import prefix, api_v0
@prefix('test_v0_pin_team')
def test_v0_pin_team(user, team):
user_name = user.create()
team_name = team.create()
team_name_2 = team.create()
# Test pinning teams
re = requests.post(api_v0('users/%s/pinned_teams' % user_name),
json={'team': team_name})
assert re.status_code == 201
re = requests.post(api_v0('users/%s/pinned_teams' % user_name),
json={'team': team_name_2})
assert re.status_code == 201
# Test getting pinned teams
re = requests.get(api_v0('users/%s/pinned_teams' % user_name))
assert re.status_code == 200
data = re.json()
assert len(data) == 2
assert team_name in data
assert team_name_2 in data
# Test deleting pinned teams
re = requests.delete(api_v0('users/%s/pinned_teams/%s' % (user_name, team_name)))
assert re.status_code == 200
re = requests.get(api_v0('users/%s/pinned_teams' % user_name))
assert re.status_code == 200
data = re.json()
assert len(data) == 1
assert team_name not in data
@prefix('test_v0_pin_invalid')
def test_api_v0_pin_invalid(user, team):
user_name = user.create()
team_name = team.create()
# Test pinning duplicate team
re = requests.post(api_v0('users/%s/pinned_teams' % user_name),
json={'team': team_name})
assert re.status_code == 201
re = requests.post(api_v0('users/%s/pinned_teams' % user_name),
json={'team': team_name})
assert re.status_code == 400
# Test pinning nonexistent team
re = requests.post(api_v0('users/%s/pinned_teams' % user_name),
json={'team': 'nonexistent-team-foobar'})
assert re.status_code == 422
# Test pinning team for nonexistent user
re = requests.post(api_v0('users/%s/pinned_teams' % 'nonexistent-user-foobar'),
json={'team': team_name})
assert re.status_code == 422
# Test deleting unpinned team
re = requests.delete(api_v0('users/%s/pinned_teams/%s' % (user_name, team_name)))
assert re.status_code == 200
re = requests.delete(api_v0('users/%s/pinned_teams/%s' % (user_name, team_name)))
assert re.status_code == 404

View File

@@ -72,6 +72,10 @@ def init(application, config):
from . import upcoming_shifts
application.add_route('/api/v0/users/{user_name}/upcoming', upcoming_shifts)
from . import user_pinned_teams, user_pinned_team
application.add_route('/api/v0/users/{user_name}/pinned_teams', user_pinned_teams)
application.add_route('/api/v0/users/{user_name}/pinned_teams/{team_name}', user_pinned_team)
# Optional Iris integration
from . import iris_settings
application.add_route('/api/v0/iris_settings', iris_settings)

View File

@@ -171,6 +171,7 @@ def on_put(req, resp, event_id):
def on_delete(req, resp, event_id):
"""
Delete an event by id, anyone on the team can delete that team's events
**Example request:**
.. sourcecode:: http

View File

@@ -0,0 +1,34 @@
from ... import db
from ...auth import login_required, check_user_auth
from falcon import HTTPNotFound
@login_required
def on_delete(req, resp, user_name, team_name):
'''
Delete a pinned team
**Example request:**
.. sourcecode:: http
DELETE /api/v0/users/jdoe/pinned_teams/team-foo HTTP/1.1
:statuscode 200: Successful delete
:statuscode 403: Delete not allowed; logged in user does not match user_name
:statuscode 404: Team not found in user's pinned teams
'''
check_user_auth(user_name, req)
connection = db.connect()
cursor = connection.cursor()
cursor.execute('''DELETE FROM `pinned_team`
WHERE `user_id` = (SELECT `id` FROM `user` WHERE `name` = %s)
AND `team_id` = (SELECT `id` FROM `team` WHERE `name` = %s)''',
(user_name, team_name))
deleted = cursor.rowcount
connection.commit()
cursor.close()
connection.close()
if deleted == 0:
raise HTTPNotFound()

View File

@@ -0,0 +1,89 @@
from ... import db
from ...utils import load_json_body
from ...auth import login_required, check_user_auth
from ujson import dumps as json_dumps
from falcon import HTTPBadRequest, HTTP_201, HTTPError
def on_get(req, resp, user_name):
'''
Get all pinned team names for a user
**Example request**:
.. sourcecode:: http
GET /api/v0/users/jdoe/pinned_teams HTTP/1.1
Host: example.com
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
[
"team-foo"
]
'''
connection = db.connect()
cursor = connection.cursor()
cursor.execute('''SELECT `team`.`name`
FROM `pinned_team` JOIN `team` ON `pinned_team`.`team_id` = `team`.`id`
WHERE `pinned_team`.`user_id` = (SELECT `id` FROM `user` WHERE `name` = %s)''',
user_name)
teams = [r[0] for r in cursor]
cursor.close()
connection.close()
resp.body = json_dumps(teams)
@login_required
def on_post(req, resp, user_name):
'''
Pin a team to the landing page for a user
**Example request**:
.. sourcecode:: http
POST /api/v0/users/jdoe/pinned_teams HTTP/1.1
Host: example.com
{
"team": "team-foo"
}
:statuscode 201: Successful team pin
:statuscode 400: Missing team parameter or team already pinned
'''
check_user_auth(user_name, req)
data = load_json_body(req)
team = data.get('team')
if team is None:
raise HTTPBadRequest('Invalid team pin', 'Missing team parameter')
connection = db.connect()
cursor = connection.cursor()
try:
cursor.execute('''INSERT INTO `pinned_team` (`user_id`, `team_id`)
VALUES ((SELECT `id` FROM `user` WHERE `name` = %s),
(SELECT `id` FROM `team` WHERE `name` = %s))''',
(user_name, team))
connection.commit()
except db.IntegrityError as e:
# Duplicate key
if e.args[0] == 1062:
raise HTTPBadRequest('Invalid team pin', 'Team already pinned for this user')
# Team/user is null
elif e.args[0] == 1048:
err_msg = str(e.args[1])
if err_msg == 'Column \'user_id\' cannot be null':
err_msg = 'user "%s" not found' % user_name
elif err_msg == 'Column \'team_id\' cannot be null':
err_msg = 'team "%s" not found' % team
raise HTTPError('422 Unprocessable Entity', 'IntegrityError', err_msg)
finally:
cursor.close()
connection.close()
resp.status = HTTP_201

View File

@@ -537,13 +537,22 @@ nav.subnav li.active {
padding: 16px;
}
.recently-viewed h3 {
body[data-authenticated="false"] #pinned-teams {
display: none;
}
.landing-teams {
display: inline-block;
width: 100%;
}
.landing-teams h3 {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #dddedf;
}
.module-card .recently-viewed-name {
.module-card .landing-teams-name {
width: calc(100% - 60px);
text-overflow: ellipsis;
white-space: nowrap;
@@ -759,6 +768,10 @@ nav.subnav li.active {
opacity: 1;
}
body[data-authenticated="false"] #pin-team {
display: none;
}
.team-name-input {
background: none;
color: #FFF;

View File

@@ -411,10 +411,12 @@ var oncall = {
summaryUrl: '/api/v0/teams/',
pageSource: $('#search-template').html(),
searchResultsSource: $('#search-results-template').html(),
cardInnerTemplate: $('#recently-viewed-inner-template').html(),
cardInnerTemplate: $('#landing-teams-inner-template').html(),
endpointTypes: ['services', 'teams'],
searchForm: '.main-search',
recentlyViewed: null
recentlyViewed: null,
pinnedTeams: null,
pinnedPromise: $.Deferred()
},
init: function(query){
var $form,
@@ -427,117 +429,130 @@ var oncall = {
self = this;
Handlebars.registerPartial('dashboard-card-inner', this.data.cardInnerTemplate);
oncall.callbacks.onLogin = function(){
self.init();
};
this.data.recentlyViewed = oncall.recentlyViewed.getItems();
this.renderPage();
this.getTeamSummaries();
$form = this.data.$page.find(this.data.searchForm);
$input = $form.find('.search-input');
if (query) {
this.getData.call(this, query);
$form.find('.search-input').val(decodeURIComponent(query.query));
if (oncall.data.user) {
$.get('/api/v0/users/' + oncall.data.user + '/pinned_teams').done(function(response){
self.data.pinnedTeams = response;
self.data.pinnedPromise.resolve();
})
} else {
this.data.pinnedPromise.resolve();
}
services = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: this.data.url + '?keyword=%QUERY',
rateLimitWait: 200,
wildcard: '%QUERY',
transform: function(resp){
var newResp = [],
this.data.pinnedPromise.done(function() {
self.renderPage();
self.getTeamSummaries();
$form = self.data.$page.find(self.data.searchForm);
$input = $form.find('.search-input');
if (query) {
self.getData.call(self, query);
$form.find('.search-input').val(decodeURIComponent(query.query));
}
services = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: self.data.url + '?keyword=%QUERY',
rateLimitWait: 200,
wildcard: '%QUERY',
transform: function(resp){
var newResp = [],
keys = Object.keys(resp.services);
servicesCt = keys.length;
for (var i = 0; i < keys.length; i++) {
newResp.push({
team: resp.services[keys[i]],
service: keys[i]
});
}
servicesCt = keys.length;
for (var i = 0; i < keys.length; i++) {
newResp.push({
team: resp.services[keys[i]],
service: keys[i]
});
}
return newResp;
}
}
});
teams = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: this.data.url + '?keyword=%QUERY',
rateLimitWait: 200,
wildcard: '%QUERY',
transform: function(resp){
teamsCt = resp.teams.length;
return resp.teams;
}
}
});
$input.typeahead(null, {
name: 'teams',
hint: true,
async: true,
highlight: true,
limit: typeaheadLimit,
source: teams,
templates: {
header: function(){
return '<h4> Teams </h4>';
},
suggestion: function(resp){
return '<div><a href="/team/' + resp + '" data-navigo>' + resp + '</a></div>';
},
footer: function(resp){
if (teamsCt > typeaheadLimit) {
return '<div class="tt-see-all"><a href="/query/' + resp.query + '/teams" data-navigo> See all ' + teamsCt + ' results for teams »</a></div>';
}
},
empty: function(resp){
return '<h4> No results found for "' + resp.query + '" </h4>';
}
}
},
{
name: 'services',
hint: true,
async: true,
highlight: true,
limit: typeaheadLimit,
displayKey: 'team',
source: services,
templates: {
header: function(){
return '<h4> Services </h4>';
},
suggestion: function(resp){
return '<div><a href="/team/' + resp.team + '" data-navigo>' + resp.service + ' - ' + '<i>' + resp.team + '</i></a></div>';
},
footer: function(resp){
if (servicesCt > typeaheadLimit) {
return '<div class="tt-see-all"><a href="/query/' + resp.query + '/services" data-navigo> See all ' + servicesCt + ' results for services »</a></div>';
return newResp;
}
}
}
});
});
teams = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: self.data.url + '?keyword=%QUERY',
rateLimitWait: 200,
wildcard: '%QUERY',
transform: function(resp){
teamsCt = resp.teams.length;
return resp.teams;
}
}
});
$input
.on('typeahead:asyncrequest', function(){
$input.parents(self.data.searchForm).addClass('loading');
})
.on('typeahead:asyncreceive', function(){
$input.parents(self.data.searchForm).removeClass('loading');
})
.on('typeahead:asynccancel', function(){
$input.parents(self.data.searchForm).removeClass('loading');
})
.on('typeahead:render', function(){
router.updatePageLinks();
})
.on('typeahead:selected', function(){
router.navigate('/team/' + $(this).val());
$input.typeahead(null, {
name: 'teams',
hint: true,
async: true,
highlight: true,
limit: typeaheadLimit,
source: teams,
templates: {
header: function(){
return '<h4> Teams </h4>';
},
suggestion: function(resp){
return '<div><a href="/team/' + resp + '" data-navigo>' + resp + '</a></div>';
},
footer: function(resp){
if (teamsCt > typeaheadLimit) {
return '<div class="tt-see-all"><a href="/query/' + resp.query + '/teams" data-navigo> See all ' + teamsCt + ' results for teams »</a></div>';
}
},
empty: function(resp){
return '<h4> No results found for "' + resp.query + '" </h4>';
}
}
},
{
name: 'services',
hint: true,
async: true,
highlight: true,
limit: typeaheadLimit,
displayKey: 'team',
source: services,
templates: {
header: function(){
return '<h4> Services </h4>';
},
suggestion: function(resp){
return '<div><a href="/team/' + resp.team + '" data-navigo>' + resp.service + ' - ' + '<i>' + resp.team + '</i></a></div>';
},
footer: function(resp){
if (servicesCt > typeaheadLimit) {
return '<div class="tt-see-all"><a href="/query/' + resp.query + '/services" data-navigo> See all ' + servicesCt + ' results for services »</a></div>';
}
}
}
});
$input
.on('typeahead:asyncrequest', function(){
$input.parents(self.data.searchForm).addClass('loading');
})
.on('typeahead:asyncreceive', function(){
$input.parents(self.data.searchForm).removeClass('loading');
})
.on('typeahead:asynccancel', function(){
$input.parents(self.data.searchForm).removeClass('loading');
})
.on('typeahead:render', function(){
router.updatePageLinks();
})
.on('typeahead:selected', function(){
router.navigate('/team/' + $(this).val());
});
});
},
events: function(){
@@ -555,7 +570,7 @@ var oncall = {
$.get(this.data.url, param, this.renderResults.bind(this));
},
getTeamSummaries: function(){
var data = this.data.recentlyViewed,
var data = this.data.pinnedTeams ? this.data.recentlyViewed.concat(this.data.pinnedTeams) : this.data.recentlyViewed,
self = this;
if (data) {
for (var i = 0; i < data.length; i++) {
@@ -581,7 +596,31 @@ var oncall = {
},
renderPage: function(){
var template = Handlebars.compile(this.data.pageSource);
this.data.$page.html(template(this.data.recentlyViewed));
this.data.$page.html(template({recent: this.data.recentlyViewed, pinned: this.data.pinnedTeams}));
this.data.$page.on('click','.remove-card-column', function(){
var $teamCard = $(this).closest('.module-card'),
$pinnedTeams = $('#pinned-teams'),
teamName = $teamCard.attr('data-team');
oncall.data.$body.addClass('loading-view');
$.ajax({
type: 'DELETE',
url: 'api/v0/users/' + oncall.data.user + '/pinned_teams/' + teamName,
dataType: 'html'
}).done(function(){
$teamCard.hide();
if ($teamCard.siblings(':visible').length === 0) {
$pinnedTeams.hide()
}
}).fail(function(data){
var error = oncall.isJson(data.responseText) ? JSON.parse(data.responseText).description : data.responseText || 'Could not unpin team.';
oncall.alerts.createAlert('Failed: ' + error, 'danger');
}).always(function(){
oncall.data.$body.removeClass('loading-view');
});
});
this.events();
},
renderCardInner: function(data){
@@ -612,7 +651,9 @@ var oncall = {
team: {
data: {
$page: $('.content-wrapper'),
$pinButton: $('#pin-team'),
url: '/api/v0/teams/',
pinUrl: '/api/v0/users/',
teamSubheaderTemplate: $('#team-subheader-template').html(),
subheaderWrapper: '.subheader-wrapper',
deleteTeam: '#delete-team',
@@ -737,6 +778,28 @@ var oncall = {
$cta.removeClass('loading disabled').prop('disabled', false);
});
},
pinTeam: function($modal) {
var $cta = $modal.find('.modal-cta'),
self = this;
$cta.addClass('loading disabled').prop('disabled', true);
$.ajax({
type: 'POST',
url: self.data.pinUrl + oncall.data.user + '/pinned_teams/',
contentType: 'application/json',
dataType: 'html',
data: JSON.stringify({team:self.data.teamName})
}).done(function(){
oncall.alerts.removeAlerts();
oncall.alerts.createAlert('Pinned team to home page', 'success');
}).fail(function(data){
var error = oncall.isJson(data.responseText) ? JSON.parse(data.responseText).description : data.responseText || 'Pinning team failed.';
oncall.alerts.createAlert(error, 'danger');
}).always(function(){
$cta.removeClass('loading disabled').prop('disabled', false);
$modal.modal('hide');
});
},
calendar: {
data: {
$page: $('.content-wrapper'),

View File

@@ -129,11 +129,39 @@
<div class="main container-fluid">
<ul class="search-results">
</ul>
{{#if this}}
<div class="recently-viewed">
{{#if this.pinned}}
<div class="landing-teams" id="pinned-teams">
<h3>Pinned Teams</h3>
<div class="card-wrap col-3">
{{#each this.pinned}}
<div class="module module-card dashboard-card card-column" data-team="{{this}}">
<div class="module-card-heading border-bottom">
<h4>
<span>
<a href="/team/{{this}}" data-navigo>{{this}}</a>
</span>
</h4>
<span class="remove-card-column">
<i class="svg-icon grey-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
<path d="M1.41 0l-1.41 1.41.72.72 1.78 1.81-1.78 1.78-.72.69 1.41 1.44.72-.72 1.81-1.81 1.78 1.81.69.72 1.44-1.44-.72-.69-1.81-1.78 1.81-1.81.72-.72-1.44-1.41-.69.72-1.78 1.78-1.81-1.78-.72-.72z" />
</svg>
</i>
</span>
</div>
<div class="dashboard-card-inner">
<i class="loader"></i>
</div>
</div>
{{/each}}
</div>
</div>
{{/if}}
{{#if this.recent}}
<div class="landing-teams" id="recent-teams">
<h3>Most Recently Viewed</h3>
<div class="card-wrap col-3">
{{#each .}}
{{#each this.recent}}
<div class="module module-card dashboard-card card-column" data-team="{{this}}">
<div class="module-card-heading border-bottom">
<h4>
@@ -199,14 +227,14 @@
{{/if}}
</script>
<!--// recently viewed partial -->
<script id="recently-viewed-inner-template" type="text/x-handlebars-template">
<!--// landing teams partial -->
<script id="landing-teams-inner-template" type="text/x-handlebars-template">
<ul class="card-inner-wrapper">
{{#ifNotEmpty data.current.primary}}
<span class="subheading light">On Call Now</span>
<li class="card-inner border-bottom" data-card-name="{{data.current.primary.0.full_name}}">
<img class="card-picture" src="{{data.current.primary.0.photo_url}}" alt="{{data.current.primary.0.full_name}}">
<h4><strong><a class="recently-viewed-name" target="_blank" href="mailto:{{data.current.primary.0.user_contacts.email}}">{{data.current.primary.0.full_name}}</a></strong> <span class="badge pull-right" data-role="{{data.current.primary.0.role}}">{{data.current.primary.0.role}}</span></h4>
<h4><strong><a class="landing-teams-name" target="_blank" href="mailto:{{data.current.primary.0.user_contacts.email}}">{{data.current.primary.0.full_name}}</a></strong> <span class="badge pull-right" data-role="{{data.current.primary.0.role}}">{{data.current.primary.0.role}}</span></h4>
{{#if data.current.primary.0.user_contacts.call}}
<span class="light"><a href="tel:{{data.current.primary.0.user_contacts.call}}">{{data.current.primary.0.user_contacts.call}}</a></span>
@@ -262,11 +290,16 @@
</h4>
{% endraw %} {% endif %} {% raw %}
</div>
<div class="pull-right" data-admin-action="true">
<div class="pull-right">
<span data-admin-action="true">
{{#isEqual page "info"}}
<button class="btn btn-white add-roster-btn" data-toggle="modal" data-target="#input-modal" data-modal-action="oncall.team.info.addRoster" data-modal-title="Add roster" data-modal-placeholder="Roster Name"> + add roster </button>
<button id="#delete-team" class="btn btn-primary" data-toggle="modal" data-target="#confirm-action-modal" data-modal-action="oncall.team.deleteTeam" data-modal-title="Delete {{name}}" data-modal-content="Delete {{name}}?"> Delete Team </button>
<button id="#delete-team" class="btn btn-white" data-toggle="modal" data-target="#confirm-action-modal" data-modal-action="oncall.team.deleteTeam" data-modal-title="Delete {{name}}" data-modal-content="Delete {{name}}?"> Delete team </button>
<button class="btn btn-white add-roster-btn" data-toggle="modal" data-target="#input-modal" data-modal-action="oncall.team.info.addRoster" data-modal-title="Add roster" data-modal-placeholder="Roster Name"> Add roster </button>
{{/isEqual}}
</span>
<span>
<button id="pin-team" class="btn btn-white" data-toggle="modal" data-target="#confirm-action-modal" data-modal-action="oncall.team.pinTeam" data-modal-title="Pin {{name}}" data-modal-content="Pin {{name}} to landing page?"> Pin to home </button>
</span>
</div>
</div>
</div>