Merge pull request #3025 from bison/outside-collaborators

Add outside collaborators view to team manager interface
This commit is contained in:
Brad Ison 2018-03-16 12:13:21 -04:00 committed by GitHub
commit d6905a0081
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 175 additions and 43 deletions

View file

@ -262,6 +262,55 @@ class OrgPrivateRepositories(ApiResource):
raise Unauthorized() raise Unauthorized()
@resource('/v1/organization/<orgname>/collaborators')
@path_param('orgname', 'The name of the organization')
class OrganizationCollaboratorList(ApiResource):
""" Resource for listing outside collaborators of an organization.
Collaborators are users that do not belong to any team in the
organiztion, but who have direct permissions on one or more
repositories belonging to the organization.
"""
@require_scope(scopes.ORG_ADMIN)
@nickname('getOrganizationCollaborators')
def get(self, orgname):
""" List outside collaborators of the specified organization. """
permission = AdministerOrganizationPermission(orgname)
if not permission.can():
raise Unauthorized()
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
all_perms = model.permission.list_organization_member_permissions(org)
membership = model.team.list_organization_members_by_teams(org)
org_members = set(m.user.username for m in membership)
collaborators = {}
for perm in all_perms:
username = perm.user.username
# Only interested in non-member permissions.
if username in org_members:
continue
if username not in collaborators:
collaborators[username] = {
'kind': 'user',
'name': username,
'avatar': avatar.get_data_for_user(perm.user),
'repositories': [],
}
collaborators[username]['repositories'].append(perm.repository.name)
return {'collaborators': collaborators.values()}
@resource('/v1/organization/<orgname>/members') @resource('/v1/organization/<orgname>/members')
@path_param('orgname', 'The name of the organization') @path_param('orgname', 'The name of the organization')
class OrganizationMemberList(ApiResource): class OrganizationMemberList(ApiResource):

View file

@ -3,10 +3,12 @@ import pytest
from data import model from data import model
from endpoints.api import api from endpoints.api import api
from endpoints.api.test.shared import conduct_api_call from endpoints.api.test.shared import conduct_api_call
from endpoints.api.organization import Organization from endpoints.api.organization import (Organization,
OrganizationCollaboratorList)
from endpoints.test.shared import client_with_identity from endpoints.test.shared import client_with_identity
from test.fixtures import * from test.fixtures import *
@pytest.mark.parametrize('expiration, expected_code', [ @pytest.mark.parametrize('expiration, expected_code', [
(0, 200), (0, 200),
(100, 400), (100, 400),
@ -17,3 +19,20 @@ def test_change_tag_expiration(expiration, expected_code, client):
conduct_api_call(cl, Organization, 'PUT', {'orgname': 'buynlarge'}, conduct_api_call(cl, Organization, 'PUT', {'orgname': 'buynlarge'},
body={'tag_expiration_s': expiration}, body={'tag_expiration_s': expiration},
expected_code=expected_code) expected_code=expected_code)
def test_get_organization_collaborators(client):
params = {'orgname': 'buynlarge'}
with client_with_identity('devtable', client) as cl:
resp = conduct_api_call(cl, OrganizationCollaboratorList, 'GET', params)
collaborator_names = [c['name'] for c in resp.json['collaborators']]
assert 'outsideorg' in collaborator_names
assert 'devtable' not in collaborator_names
assert 'reader' not in collaborator_names
for collaborator in resp.json['collaborators']:
if collaborator['name'] == 'outsideorg':
assert 'orgrepo' in collaborator['repositories']
assert 'anotherorgrepo' not in collaborator['repositories']

View file

@ -4,6 +4,7 @@ import pytest
from flask_principal import AnonymousIdentity from flask_principal import AnonymousIdentity
from endpoints.api import api from endpoints.api import api
from endpoints.api.organization import OrganizationCollaboratorList
from endpoints.api.repositorynotification import RepositoryNotification from endpoints.api.repositorynotification import RepositoryNotification
from endpoints.api.permission import RepositoryUserTransitivePermission from endpoints.api.permission import RepositoryUserTransitivePermission
from endpoints.api.team import OrganizationTeamSyncing from endpoints.api.team import OrganizationTeamSyncing
@ -19,6 +20,7 @@ from endpoints.test.shared import client_with_identity, toggle_feature
from test.fixtures import * from test.fixtures import *
ORG_PARAMS = {'orgname': 'buynlarge'}
TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'} TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'}
BUILD_PARAMS = {'build_uuid': 'test-1234'} BUILD_PARAMS = {'build_uuid': 'test-1234'}
REPO_PARAMS = {'repository': 'devtable/someapp'} REPO_PARAMS = {'repository': 'devtable/someapp'}
@ -48,6 +50,11 @@ TRIGGER_PARAMS = {'repository': 'devtable/simple', 'trigger_uuid': 'someuuid'}
(AppToken, 'DELETE', TOKEN_PARAMS, {}, 'reader', 404), (AppToken, 'DELETE', TOKEN_PARAMS, {}, 'reader', 404),
(AppToken, 'DELETE', TOKEN_PARAMS, {}, 'devtable', 404), (AppToken, 'DELETE', TOKEN_PARAMS, {}, 'devtable', 404),
(OrganizationCollaboratorList, 'GET', ORG_PARAMS, None, None, 401),
(OrganizationCollaboratorList, 'GET', ORG_PARAMS, None, 'freshuser', 403),
(OrganizationCollaboratorList, 'GET', ORG_PARAMS, None, 'reader', 403),
(OrganizationCollaboratorList, 'GET', ORG_PARAMS, None, 'devtable', 200),
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, None, 403), (OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, None, 403),
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'freshuser', 403), (OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'freshuser', 403),
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'reader', 403), (OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'reader', 403),

View file

@ -12,19 +12,23 @@
<div class="tab-header-controls hidden-xs"> <div class="tab-header-controls hidden-xs">
<div class="btn-group btn-group-sm" ng-show="organization.is_admin"> <div class="btn-group btn-group-sm" ng-show="organization.is_admin">
<button class="btn" <button class="btn"
ng-class="!showingMembers ? 'btn-primary active' : 'btn-default'" ng-click="showMembers(false)"> ng-class="activeView == views.TEAMS ? 'btn-primary active' : 'btn-default'" ng-click="setActiveView(views.TEAMS)">
<i class="fa fa-group"></i>Teams View <i class="fa fa-group"></i>Teams View
</button> </button>
<button class="btn" <button class="btn"
ng-class="showingMembers ? 'btn-info active' : 'btn-default'" ng-click="showMembers(true)"> ng-class="activeView == views.MEMBERS ? 'btn-info active' : 'btn-default'" ng-click="setActiveView(views.MEMBERS)">
<i class="fa fa-user"></i>Members View <i class="fa fa-user"></i>Members View
</button> </button>
<button class="btn"
ng-class="activeView == views.COLLABORATORS ? 'btn-info active' : 'btn-default'" ng-click="setActiveView(views.COLLABORATORS)">
<i class="fa fa-user-circle"></i>Collaborators View
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Teams List --> <!-- Teams List -->
<div ng-show="!showingMembers"> <div ng-show="activeView == views.TEAMS">
<button class="btn btn-primary hidden-xs" <button class="btn btn-primary hidden-xs"
ng-show="organization.is_admin" ng-show="organization.is_admin"
style="margin-bottom: 10px; " style="margin-bottom: 10px; "
@ -127,35 +131,35 @@
</div> </div>
</div> </div>
<!-- Members List --> <!-- Members or Collaborators List -->
<div ng-show="showingMembers"> <div ng-show="activeView == views.MEMBERS || activeView == views.COLLABORATORS">
<div class="cor-loader" ng-if="!fullMemberList"></div> <div class="cor-loader" ng-if="!usersView"></div>
<div class="filter-box" collection="fullMemberList" filter-model="memberFilter" <div class="filter-box" collection="usersView" filter-model="userFilter"
filter-name="Organization Members"></div> filter-name="Users"></div>
<div class="empty" ng-if="fullMemberList.length && !(fullMemberList | filter:memberFilter).length"> <div class="empty" ng-if="usersView.length && !(usersView | filter:userFilter).length">
<div class="empty-primary-msg">No organization members found matching filter.</div> <div class="empty-primary-msg">No users found matching filter.</div>
<div class="empty-secondary-msg"> <div class="empty-secondary-msg">
Please change your filter to display members. Please change your filter to display users.
</div> </div>
</div> </div>
<table class="cor-table" ng-if="(fullMemberList | filter:memberFilter).length"> <table class="cor-table">
<thead> <thead>
<td>Member Name</td> <td>User Name</td>
<td>Teams</td> <td ng-if="activeView == views.MEMBERS">Teams</td>
<td>Direct Repository Permissions</td> <td>Direct Repository Permissions</td>
<td class="options-col"></td> <td class="options-col"></td>
</thead> </thead>
<tbody ng-repeat="memberInfo in fullMemberList | filter:memberFilter | orderBy:'name'" bindonce> <tbody ng-repeat="orgUser in usersView | filter:userFilter | orderBy:'name'" bindonce>
<tr> <tr>
<td> <td>
<div class="entity-reference" entity="memberInfo"></div> <div class="entity-reference" entity="orgUser"></div>
</td> </td>
<td> <td ng-if="activeView == views.MEMBERS">
<span ng-repeat="team in memberInfo.teams" <span ng-repeat="team in orgUser.teams"
data-title="Team {{ team.name }}" bs-tooltip> data-title="Team {{ team.name }}" bs-tooltip>
<span class="anchor" href="/organization/{{ organization.name }}/teams/{{ team.name }}"> <span class="anchor" href="/organization/{{ organization.name }}/teams/{{ team.name }}">
<span class="avatar" size="24" data="team.avatar"></span> <span class="avatar" size="24" data="team.avatar"></span>
@ -163,20 +167,20 @@
</span> </span>
</td> </td>
<td> <td>
<span class="empty" bo-if="memberInfo.repositories.length == 0"> <span class="empty" bo-if="orgUser.repositories.length == 0">
(No direct permissions on any repositories) (No direct permissions on any repositories)
</span> </span>
<span class="member-perm-summary" bo-if="memberInfo.repositories.length > 0"> <span class="member-perm-summary" bo-if="orgUser.repositories.length > 0">
Direct permissions on {{ memberInfo.repositories.length }} Direct permissions on {{ orgUser.repositories.length }}
<span bo-if="memberInfo.repositories.length == 1">repository</span> <span bo-if="orgUser.repositories.length == 1">repository</span>
<span bo-if="memberInfo.repositories.length > 1">repositories</span> <span bo-if="orgUser.repositories.length > 1">repositories</span>
under this organization under this organization
</span> </span>
</td> </td>
<td class="options-col"> <td class="options-col">
<span class="cor-options-menu" ng-if="memberInfo.name != user.username"> <span class="cor-options-menu" ng-if="orgUser.name != user.username">
<span class="cor-option" option-click="askRemoveMember(memberInfo)"> <span class="cor-option" option-click="askRemoveMember(orgUser)">
<i class="fa fa-times"></i> Remove From Organization <i class="fa fa-times"></i> Remove From Organization
</span> </span>
</span> </span>
@ -197,8 +201,8 @@
<div class="cor-confirm-dialog" <div class="cor-confirm-dialog"
dialog-context="removeMemberInfo" dialog-context="removeMemberInfo"
dialog-action="removeMember(info, callback)" dialog-action="removeMember(info, callback)"
dialog-title="Remove Organization Member" dialog-title="Remove User from Organization"
dialog-action-title="Remove Member"> dialog-action-title="Remove">
<div class="co-alert co-alert-info" style="margin-bottom: 10px;"> <div class="co-alert co-alert-info" style="margin-bottom: 10px;">
<span class="entity-reference" entity="removeMemberInfo"></span> will be removed from all teams and repositories under this organization in which they are a <span class="entity-reference" entity="removeMemberInfo"></span> will be removed from all teams and repositories under this organization in which they are a
member or have permissions. member or have permissions.

View file

@ -17,6 +17,12 @@ angular.module('quay').directive('teamsManager', function () {
$scope.Config = Config; $scope.Config = Config;
$scope.Features = Features; $scope.Features = Features;
$scope.views = Object.freeze({
TEAMS: 0,
MEMBERS: 1,
COLLABORATORS: 2
});
$scope.options = { $scope.options = {
'predicate': 'ordered_team_index', 'predicate': 'ordered_team_index',
'reverse': false, 'reverse': false,
@ -35,8 +41,11 @@ angular.module('quay').directive('teamsManager', function () {
$scope.orderedTeams = null; $scope.orderedTeams = null;
$scope.showingMembers = false; $scope.showingMembers = false;
$scope.fullMemberList = null; $scope.fullMemberList = null;
$scope.collaboratorList = null;
$scope.userView = null;
$scope.feedback = null; $scope.feedback = null;
$scope.createTeamInfo = null; $scope.createTeamInfo = null;
$scope.activeView = $scope.views.TEAMS;
var getRoleIndex = function(name) { var getRoleIndex = function(name) {
for (var i = 0; i < $scope.teamRoles.length; ++i) { for (var i = 0; i < $scope.teamRoles.length; ++i) {
@ -67,6 +76,62 @@ angular.module('quay').directive('teamsManager', function () {
['ordered_team_index', 'member_count', 'repo_count', 'role_index']); ['ordered_team_index', 'member_count', 'repo_count', 'role_index']);
}; };
var loadMembers = function(callback) {
var params = {
'orgname': $scope.organization.name
};
ApiService.getOrganizationMembers(null, params).then(function(resp) {
$scope.fullMemberList = resp['members'];
callback();
}, ApiService.errorDisplay('Could not load full membership list'));
};
var loadCollaborators = function(callback) {
var params = {
'orgname': $scope.organization.name
};
ApiService.getOrganizationCollaborators(null, params).then(function(resp) {
$scope.collaboratorList = resp['collaborators'];
callback();
}, ApiService.errorDisplay('Could not load collaborators list'));
};
$scope.setActiveView = function(view) {
switch(view) {
case $scope.views.TEAMS:
// Nothing to do here.
break;
case $scope.views.MEMBERS:
if (!$scope.fullMemberList) {
loadMembers(function() {
$scope.usersView = $scope.fullMemberList;
});
}
$scope.usersView = $scope.fullMemberList;
break;
case $scope.views.COLLABORATORS:
if (!$scope.collaboratorList) {
loadCollaborators(function() {
$scope.usersView = $scope.collaboratorList;
});
}
$scope.usersView = $scope.collaboratorList;
break;
default:
console.error('Invalid team-manager view: ' + view);
return;
}
$scope.activeView = view;
}
$scope.setRole = function(role, teamname) { $scope.setRole = function(role, teamname) {
var previousRole = $scope.organization.teams[teamname].role; var previousRole = $scope.organization.teams[teamname].role;
$scope.organization.teams[teamname].role = role; $scope.organization.teams[teamname].role = role;
@ -158,19 +223,6 @@ angular.module('quay').directive('teamsManager', function () {
$location.path('/organization/' + $scope.organization.name + '/teams/' + teamName); $location.path('/organization/' + $scope.organization.name + '/teams/' + teamName);
}; };
$scope.showMembers = function(value) {
$scope.showingMembers = value;
if (value && !$scope.fullMemberList) {
var params = {
'orgname': $scope.organization.name
};
ApiService.getOrganizationMembers(null, params).then(function(resp) {
$scope.fullMemberList = resp['members'];
}, ApiService.errorDisplay('Could not load full membership list'));
}
};
$scope.removeMember = function(memberInfo, callback) { $scope.removeMember = function(memberInfo, callback) {
var params = { var params = {
'orgname': $scope.organization.name, 'orgname': $scope.organization.name,
@ -184,7 +236,8 @@ angular.module('quay').directive('teamsManager', function () {
ApiService.removeOrganizationMember(null, params).then(function(resp) { ApiService.removeOrganizationMember(null, params).then(function(resp) {
// Reset the state of the directive. // Reset the state of the directive.
$scope.fullMemberList = null; $scope.fullMemberList = null;
$scope.showMembers(true); $scope.collaboratorList = null;
$scope.setActiveView($scope.activeView);
callback(true); callback(true);