Merge pull request #3025 from bison/outside-collaborators
Add outside collaborators view to team manager interface
This commit is contained in:
commit
d6905a0081
5 changed files with 175 additions and 43 deletions
|
@ -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):
|
||||||
|
|
|
@ -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']
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
Reference in a new issue