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()
@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')
@path_param('orgname', 'The name of the organization')
class OrganizationMemberList(ApiResource):

View file

@ -3,10 +3,12 @@ import pytest
from data import model
from endpoints.api import api
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 test.fixtures import *
@pytest.mark.parametrize('expiration, expected_code', [
(0, 200),
(100, 400),
@ -17,3 +19,20 @@ def test_change_tag_expiration(expiration, expected_code, client):
conduct_api_call(cl, Organization, 'PUT', {'orgname': 'buynlarge'},
body={'tag_expiration_s': expiration},
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 endpoints.api import api
from endpoints.api.organization import OrganizationCollaboratorList
from endpoints.api.repositorynotification import RepositoryNotification
from endpoints.api.permission import RepositoryUserTransitivePermission
from endpoints.api.team import OrganizationTeamSyncing
@ -19,6 +20,7 @@ from endpoints.test.shared import client_with_identity, toggle_feature
from test.fixtures import *
ORG_PARAMS = {'orgname': 'buynlarge'}
TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'}
BUILD_PARAMS = {'build_uuid': 'test-1234'}
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, {}, '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, {}, 'freshuser', 403),
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'reader', 403),

View file

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

View file

@ -17,6 +17,12 @@ angular.module('quay').directive('teamsManager', function () {
$scope.Config = Config;
$scope.Features = Features;
$scope.views = Object.freeze({
TEAMS: 0,
MEMBERS: 1,
COLLABORATORS: 2
});
$scope.options = {
'predicate': 'ordered_team_index',
'reverse': false,
@ -35,8 +41,11 @@ angular.module('quay').directive('teamsManager', function () {
$scope.orderedTeams = null;
$scope.showingMembers = false;
$scope.fullMemberList = null;
$scope.collaboratorList = null;
$scope.userView = null;
$scope.feedback = null;
$scope.createTeamInfo = null;
$scope.activeView = $scope.views.TEAMS;
var getRoleIndex = function(name) {
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']);
};
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) {
var previousRole = $scope.organization.teams[teamname].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);
};
$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) {
var params = {
'orgname': $scope.organization.name,
@ -184,7 +236,8 @@ angular.module('quay').directive('teamsManager', function () {
ApiService.removeOrganizationMember(null, params).then(function(resp) {
// Reset the state of the directive.
$scope.fullMemberList = null;
$scope.showMembers(true);
$scope.collaboratorList = null;
$scope.setActiveView($scope.activeView);
callback(true);
@ -226,4 +279,4 @@ angular.module('quay').directive('teamsManager', function () {
};
return directiveDefinitionObject;
});
});