user-groups: new selector for user group memebers.

- removed old two column selector in replacement of autocomplete list
- scales much better for alrge number of users
- still allows to select single users and user groups
- fixes #4291 (user group shows only username in members)
This commit is contained in:
Marcin Kuzminski 2016-11-06 16:01:21 +01:00
parent a218d75e0a
commit cbafa9652e
15 changed files with 206 additions and 299 deletions

View file

@ -67,7 +67,6 @@
"<%= dirs.js.src %>/rhodecode/utils/ie.js",
"<%= dirs.js.src %>/rhodecode/utils/os.js",
"<%= dirs.js.src %>/rhodecode/utils/topics.js",
"<%= dirs.js.src %>/rhodecode/widgets/multiselect.js",
"<%= dirs.js.src %>/rhodecode/init.js",
"<%= dirs.js.src %>/rhodecode/codemirror.js",
"<%= dirs.js.src %>/rhodecode/comments.js",

View file

@ -305,7 +305,7 @@ def make_map(config):
m.connect('delete_user', '/users/{user_id}',
action='delete', conditions={'method': ['DELETE']})
m.connect('edit_user', '/users/{user_id}/edit',
action='edit', conditions={'method': ['GET']})
action='edit', conditions={'method': ['GET']}, jsroute=True)
m.connect('user', '/users/{user_id}',
action='show', conditions={'method': ['GET']})
m.connect('force_password_reset_user', '/users/{user_id}/password_reset',
@ -389,7 +389,7 @@ def make_map(config):
m.connect('edit_user_group_members',
'/user_groups/{user_group_id}/edit/members', jsroute=True,
action='edit_members', conditions={'method': ['GET']})
action='user_group_members', conditions={'method': ['GET']})
# ADMIN PERMISSIONS ROUTES
with rmap.submapper(path_prefix=ADMIN_PREFIX,

View file

@ -25,6 +25,7 @@ User Groups crud controller for pylons
import logging
import formencode
import peppercorn
from formencode import htmlfill
from pylons import request, tmpl_context as c, url, config
from pylons.controllers.util import redirect
@ -40,7 +41,7 @@ from rhodecode.lib.utils import jsonify, action_logger
from rhodecode.lib.utils2 import safe_unicode, str2bool, safe_int
from rhodecode.lib.auth import (
LoginRequired, NotAnonymous, HasUserGroupPermissionAnyDecorator,
HasPermissionAnyDecorator)
HasPermissionAnyDecorator, XHRRequired)
from rhodecode.lib.base import BaseController, render
from rhodecode.model.permission import PermissionModel
from rhodecode.model.scm import UserGroupList
@ -69,13 +70,8 @@ class UserGroupsController(BaseController):
def __load_data(self, user_group_id):
c.group_members_obj = [x.user for x in c.user_group.members]
c.group_members_obj.sort(key=lambda u: u.username.lower())
c.group_members = [(x.user_id, x.username) for x in c.group_members_obj]
c.available_members = [(x.user_id, x.username)
for x in User.query().all()]
c.available_members.sort(key=lambda u: u[1].lower())
def __load_defaults(self, user_group_id):
"""
Load defaults settings for edit, and update
@ -207,20 +203,21 @@ class UserGroupsController(BaseController):
c.active = 'settings'
self.__load_data(user_group_id)
available_members = [safe_unicode(x[0]) for x in c.available_members]
users_group_form = UserGroupForm(
edit=True, old_data=c.user_group.get_dict(),
available_members=available_members, allow_disabled=True)()
edit=True, old_data=c.user_group.get_dict(), allow_disabled=True)()
try:
form_result = users_group_form.to_python(request.POST)
pstruct = peppercorn.parse(request.POST.items())
form_result['users_group_members'] = pstruct['user_group_members']
UserGroupModel().update(c.user_group, form_result)
gr = form_result['users_group_name']
updated_user_group = form_result['users_group_name']
action_logger(c.rhodecode_user,
'admin_updated_users_group:%s' % gr,
'admin_updated_users_group:%s' % updated_user_group,
None, self.ip_addr, self.sa)
h.flash(_('Updated user group %s') % gr, category='success')
h.flash(_('Updated user group %s') % updated_user_group,
category='success')
Session().commit()
except formencode.Invalid as errors:
defaults = errors.value
@ -462,19 +459,29 @@ class UserGroupsController(BaseController):
return render('admin/user_groups/user_group_edit.html')
@HasUserGroupPermissionAnyDecorator('usergroup.admin')
def edit_members(self, user_group_id):
@XHRRequired()
@jsonify
def user_group_members(self, user_group_id):
user_group_id = safe_int(user_group_id)
c.user_group = UserGroup.get_or_404(user_group_id)
c.active = 'members'
c.group_members_obj = sorted((x.user for x in c.user_group.members),
key=lambda u: u.username.lower())
user_group = UserGroup.get_or_404(user_group_id)
group_members_obj = sorted((x.user for x in user_group.members),
key=lambda u: u.username.lower())
group_members = [(x.user_id, x.username) for x in c.group_members_obj]
group_members = [
{
'id': user.user_id,
'first_name': user.name,
'last_name': user.lastname,
'username': user.username,
'icon_link': h.gravatar_url(user.email, 30),
'value_display': h.person(user.email),
'value': user.username,
'value_type': 'user',
'active': user.active,
}
for user in group_members_obj
]
if request.is_xhr:
return jsonify(lambda *a, **k: {
'members': group_members
})
c.group_members = group_members
return render('admin/user_groups/user_group_edit.html')
return {
'members': group_members
}

View file

@ -143,10 +143,8 @@ def UserForm(edit=False, available_languages=[], old_data={}):
return _UserForm
def UserGroupForm(edit=False, old_data=None, available_members=None,
allow_disabled=False):
def UserGroupForm(edit=False, old_data=None, allow_disabled=False):
old_data = old_data or {}
available_members = available_members or []
class _UserGroupForm(formencode.Schema):
allow_extra_fields = True
@ -162,10 +160,6 @@ def UserGroupForm(edit=False, old_data=None, available_members=None,
users_group_active = v.StringBoolean(if_missing=False)
if edit:
users_group_members = v.OneOf(
available_members, hideList=False, testValueList=True,
if_missing=None, not_empty=False
)
# this is user group owner
user = All(
v.UnicodeString(not_empty=True),

View file

@ -189,18 +189,15 @@ class UserGroupModel(BaseModel):
self._log_user_changes('removed from', user_group, removed)
def _clean_members_data(self, members_data):
# TODO: anderson: this should be in the form validation but I couldn't
# make it work there as it conflicts with the other validator
if not members_data:
members_data = []
if isinstance(members_data, basestring):
new_members = [members_data]
else:
new_members = members_data
new_members = [int(uid) for uid in new_members]
return new_members
members = []
for user in members_data:
uid = int(user['member_user_id'])
if uid not in members and user['type'] in ['new', 'existing']:
members.append(uid)
return members
def update(self, user_group, form_data):
user_group = self._get_user_group(user_group)

View file

@ -1370,7 +1370,27 @@ table.integrations {
margin-right: .5em;
margin-left: 3px;
}
.to-delete {
.user {
text-decoration: line-through;
}
}
}
// new entry in group_members
.td-author-new-entry {
background-color: rgba(red(@alert1), green(@alert1), blue(@alert1), 0.3);
}
.usergroup_member_remove {
width: 16px;
margin-bottom: 10px;
padding: 0;
color: black !important;
cursor: pointer;
}
.reviewer_ac .ac-input {
width: 92%;
margin-bottom: 1em;

View file

@ -16,6 +16,7 @@ function registerRCRoutes() {
pyroutes.register('user_autocomplete_data', '/_users', []);
pyroutes.register('user_group_autocomplete_data', '/_user_groups', []);
pyroutes.register('new_repo', '/_admin/create_repository', []);
pyroutes.register('edit_user', '/_admin/users/%(user_id)s/edit', ['user_id']);
pyroutes.register('edit_user_group_members', '/_admin/user_groups/%(user_group_id)s/edit/members', ['user_group_id']);
pyroutes.register('gists', '/_admin/gists', []);
pyroutes.register('new_gist', '/_admin/gists/new', []);

View file

@ -1,132 +0,0 @@
// # Copyright (C) 2010-2016 RhodeCode GmbH
// #
// # This program is free software: you can redistribute it and/or modify
// # it under the terms of the GNU Affero General Public License, version 3
// # (only), as published by the Free Software Foundation.
// #
// # This program is distributed in the hope that it will be useful,
// # but WITHOUT ANY WARRANTY; without even the implied warranty of
// # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// # GNU General Public License for more details.
// #
// # You should have received a copy of the GNU Affero General Public License
// # along with this program. If not, see <http://www.gnu.org/licenses/>.
// #
// # This program is dual-licensed. If you wish to learn more about the
// # RhodeCode Enterprise Edition, including its added features, Support services,
// # and proprietary license terms, please see https://rhodecode.com/licenses/
/**
* Multi select widget
*/
var MultiSelectWidget = function(selected_id, available_id, form_id){
// definition of containers ID's
var selected_container = selected_id;
var available_container = available_id;
// temp container for selected storage.
var cache = [];
var av_cache = [];
var c = $('#'+selected_container).get(0);
var ac = $('#'+available_container).get(0);
// get only selected options for further fullfilment
for (var i = 0; node = c.options[i]; i++){
if (node.selected){
// push selected to my temp storage left overs :)
cache.push(node);
}
}
// get all available options to cache
for (i = 0; node = ac.options[i]; i++){
// push selected to my temp storage left overs :)
av_cache.push(node);
}
// fill available only with those not in chosen
ac.options.length = 0;
tmp_cache = [];
for (i = 0; node = av_cache[i]; i++){
var add = true;
for (var i2 = 0; node_2 = cache[i2]; i2++){
if (node.value === node_2.value){
add=false;
break;
}
}
if(add){
tmp_cache.push(new Option(node.text, node.value, false, false));
}
}
for (i = 0; node = tmp_cache[i]; i++){
ac.options[i] = node;
}
function prompts_action_callback(e){
var chosen = $('#'+selected_container).get(0);
var available = $('#'+available_container).get(0);
// get checked and unchecked options from field
function get_checked(from_field){
// temp container for storage.
var sel_cache = [];
var oth_cache = [];
for (i = 0; node = from_field.options[i]; i++){
if(node.selected){
// push selected fields :)
sel_cache.push(node);
}
else {
oth_cache.push(node);
}
}
return [sel_cache,oth_cache];
}
// fill the field with given options
function fill_with(field,options){
// clear firtst
field.options.length=0;
for (var i = 0; node = options[i]; i++){
field.options[i] = new Option(node.text, node.value, false, false);
}
}
// adds to current field
function add_to(field,options){
for (i = 0; node = options[i]; i++){
field.appendChild(new Option(node.text, node.value, false, false));
}
}
// add action
if (this.id ==='add_element'){
var c = get_checked(available);
add_to(chosen,c[0]);
fill_with(available,c[1]);
}
// remove action
if (this.id ==='remove_element'){
c = get_checked(chosen);
add_to(available,c[0]);
fill_with(chosen,c[1]);
}
// add all elements
if(this.id === 'add_all_elements'){
for (i=0; node = available.options[i]; i++){
chosen.appendChild(new Option(node.text, node.value, false, false));
}
available.options.length = 0;
}
// remove all elements
if (this.id === 'remove_all_elements'){
for (i=0; node = chosen.options[i]; i++){
available.appendChild(new Option(node.text, node.value, false, false));
}
chosen.options.length = 0;
}
}
$('#add_element, #remove_element, #add_all_elements, #remove_all_elements').click(prompts_action_callback);
if (form_id !== undefined) {
$('#'+form_id).submit(function(){
var chosen = $('#'+selected_container).get(0);
for (i = 0; i < chosen.options.length; i++) {
chosen.options[i].selected = 'selected';
}
});
}
};

View file

@ -35,7 +35,6 @@
<li class="${'active' if c.active=='advanced' else ''}"><a href="${h.url('edit_user_group_advanced', user_group_id=c.user_group.users_group_id)}">${_('Advanced')}</a></li>
<li class="${'active' if c.active=='global_perms' else ''}"><a href="${h.url('edit_user_group_global_perms', user_group_id=c.user_group.users_group_id)}">${_('Global permissions')}</a></li>
<li class="${'active' if c.active=='perms_summary' else ''}"><a href="${h.url('edit_user_group_perms_summary', user_group_id=c.user_group.users_group_id)}">${_('Permissions summary')}</a></li>
<li class="${'active' if c.active=='members' else ''}"><a href="${h.url('edit_user_group_members', user_group_id=c.user_group.users_group_id)}">${_('Members')}</a></li>
</ul>
</div>

View file

@ -1,30 +0,0 @@
<%namespace name="base" file="/base/base.html"/>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">${_('Members of User Group: %s') % c.user_group.users_group_name}</h3>
</div>
<div class="panel-body">
% if c.group_members_obj:
<table class="rctable group_members">
<tr>
<th>Username</th>
<th>Name</th>
</tr>
%for user in c.group_members_obj:
<tr>
<td class="td-author">
<div class="group_member">
${base.gravatar(user.email, 16)}
<span class="username user">${h.link_to(user.username, h.url( 'edit_user',user_id=user.user_id))}</span>
</div>
</td>
<td class="fullname">${user.full_name}</td>
</tr>
%endfor
</table>
%else:
<p class="empty_data">${_('No members yet')}</p>
%endif
</div>
</div>

View file

@ -54,40 +54,53 @@
${h.checkbox('users_group_active',value=True)}
</div>
</div>
<div class="field">
<div class="label">
<label for="users_group_active">${_('Search')}:</label>
${h.text('from_user_group',
placeholder="user/usergroup",
class_="medium")}
<div class="field">
<div class="label label-checkbox">
<label for="users_group_active">${_('Add members')}:</label>
</div>
<div class="select side-by-side-selector">
<div class="left-group">
<label class="text"><strong>${_('Chosen group members')}</strong></label>
${h.select('users_group_members',[x[0] for x in c.group_members],c.group_members,multiple=True,size=8,)}
<div class="btn" id="remove_all_elements" >
${_('Remove all elements')}
<i class="icon-chevron-right"></i>
</div>
</div>
<div class="middle-group">
<i id="add_element" class="icon-chevron-left"></i>
<br />
<i id="remove_element" class="icon-chevron-right"></i>
</div>
<div class="right-group">
<label class="text" >${_('Available users')}
</label>
${h.select('available_members',[],c.available_members,multiple=True,size=8,)}
<div class="btn" id="add_all_elements" >
<i class="icon-chevron-left"></i>${_('Add all elements')}
</div>
</div>
<div class="input">
${h.text('user_group_add_members', placeholder="user/usergroup", class_="medium")}
</div>
</div>
<div class="buttons">
${h.submit('Save',_('Save'),class_="btn")}
</div>
</div>
<input type="hidden" name="__start__" value="user_group_members:sequence"/>
<table id="group_members_placeholder" class="rctable group_members">
<tr>
<th>${_('Username')}</th>
<th>${_('Action')}</th>
</tr>
% if c.group_members_obj:
% for user in c.group_members_obj:
<tr>
<td id="member_user_${user.user_id}" class="td-author">
<div class="group_member">
${base.gravatar(user.email, 16)}
<span class="username user">${h.link_to(h.person(user), h.url( 'edit_user',user_id=user.user_id))}</span>
<input type="hidden" name="__start__" value="member:mapping">
<input type="hidden" name="member_user_id" value="${user.user_id}">
<input type="hidden" name="type" value="existing" id="member_${user.user_id}">
<input type="hidden" name="__end__" value="member:mapping">
</div>
</td>
<td class="">
<div class="usergroup_member_remove action_button" onclick="removeUserGroupMember(${user.user_id}, true)" style="visibility: visible;">
<i class="icon-remove-sign"></i>
</div>
</td>
</tr>
% endfor
% else:
<tr><td colspan="2">${_('No members yet')}</td></tr>
% endif
</table>
<input type="hidden" name="__end__" value="user_group_members:sequence"/>
<div class="buttons">
${h.submit('Save',_('Save'),class_="btn")}
</div>
</div>
</div>
${h.end_form()}
@ -95,15 +108,18 @@
</div>
<script>
$(document).ready(function(){
MultiSelectWidget('users_group_members','available_members','edit_users_group');
$("#group_parent_id").select2({
'containerCssClass': "drop-menu",
'dropdownCssClass': "drop-menu-dropdown",
'dropdownAutoWidth': true
});
$('#from_user_group').autocomplete({
removeUserGroupMember = function(userId){
$('#member_'+userId).val('remove');
$('#member_user_'+userId).addClass('to-delete');
};
$('#user_group_add_members').autocomplete({
serviceUrl: pyroutes.url('user_autocomplete_data'),
minChars:2,
maxHeight:400,
@ -115,9 +131,37 @@
lookupFilter: autocompleteFilterResult,
onSelect: function(element, suggestion){
function preSelectUserIds(uids) {
$('#available_members').val(uids);
$('#users_group_members').val(uids);
function addMember(user, fromUserGroup) {
var gravatar = user.icon_link;
var username = user.value_display;
var userLink = pyroutes.url('edit_user', {"user_id": user.id});
var uid = user.id;
if (fromUserGroup) {
username = username +" "+ _gettext('(from usergroup {0})'.format(fromUserGroup))
}
var elem = $(
('<tr>'+
'<td id="member_user_{6}" class="td-author td-author-new-entry">'+
'<div class="group_member">'+
'<img class="gravatar" src="{0}" height="16" width="16">'+
'<span class="username user"><a href="{1}">{2}</a></span>'+
'<input type="hidden" name="__start__" value="member:mapping">'+
'<input type="hidden" name="member_user_id" value="{3}">'+
'<input type="hidden" name="type" value="new" id="member_{4}">'+
'<input type="hidden" name="__end__" value="member:mapping">'+
'</div>'+
'</td>'+
'<td class="td-author-new-entry">'+
'<div class="usergroup_member_remove action_button" onclick="removeUserGroupMember({5}, true)" style="visibility: visible;">'+
'<i class="icon-remove-sign"></i>'+
'</div>'+
'</td>'+
'</tr>').format(gravatar, userLink, username,
uid, uid, uid, uid)
);
$('#group_members_placeholder').append(elem)
}
if (suggestion.value_type == 'user_group') {
@ -125,20 +169,18 @@
pyroutes.url('edit_user_group_members',
{'user_group_id': suggestion.id}),
function(data) {
var uids = [];
$.each(data.members, function(idx, user) {
var userid = user[0],
username = user[1];
uids.push(userid.toString());
addMember(user, suggestion.value)
});
preSelectUserIds(uids)
}
);
} else if (suggestion.value_type == 'user') {
preSelectUserIds([suggestion.id.toString()]);
addMember(suggestion, null);
}
}
});
UsersAutoComplete('user', '${c.rhodecode_user.user_id}');
})
</script>

View file

@ -545,12 +545,6 @@
</div>
</div>
<script>
$(document).ready(function(){
MultiSelectWidget('users_group_members','available_members','edit_users_group');
})
</script>
</div>
<div class='field'>
@ -595,12 +589,6 @@
</div>
</div>
<script>
$(document).ready(function(){
MultiSelectWidget('users_group_members2','available_members','edit_users_group');
})
</script>
</div>
<div class='field'>

View file

@ -36,7 +36,7 @@ class TestAdminUsersGroupsController(TestController):
def test_index(self):
self.log_user()
response = self.app.get(url('users_groups'))
response.status_int == 200
assert response.status_int == 200
def test_create(self):
self.log_user()
@ -148,19 +148,21 @@ class TestAdminUsersGroupsController(TestController):
fixture.destroy_user_group(users_group_name)
def test_edit(self):
def test_edit_autocomplete(self):
self.log_user()
ug = fixture.create_user_group(TEST_USER_GROUP, skip_if_exists=True)
response = self.app.get(
url('edit_users_group', user_group_id=ug.users_group_id))
fixture.destroy_user_group(TEST_USER_GROUP)
def test_edit_user_group_members(self):
def test_edit_user_group_autocomplete_members(self, xhr_header):
self.log_user()
ug = fixture.create_user_group(TEST_USER_GROUP, skip_if_exists=True)
response = self.app.get(
url('edit_user_group_members', user_group_id=ug.users_group_id))
response.mustcontain('No members yet')
url('edit_user_group_members', user_group_id=ug.users_group_id),
extra_environ=xhr_header)
assert response.body == '{"members": []}'
fixture.destroy_user_group(TEST_USER_GROUP)
def test_usergroup_escape(self):
@ -181,7 +183,7 @@ class TestAdminUsersGroupsController(TestController):
'csrf_token': self.csrf_token
}
response = self.app.post(url('users_groups'), data)
self.app.post(url('users_groups'), data)
response = self.app.get(url('users_groups'))
response.mustcontain(
@ -190,3 +192,42 @@ class TestAdminUsersGroupsController(TestController):
response.mustcontain(
'&lt;img src=&#34;/image2&#34; onload=&#34;'
'alert(&#39;Hello, World!&#39;);&#34;&gt;')
def test_update_members_from_user_ids(self, user_regular):
uid = user_regular.user_id
username = user_regular.username
self.log_user()
user_group = fixture.create_user_group('test_gr_ids')
assert user_group.members == []
assert user_group.user != user_regular
expected_active_state = not user_group.users_group_active
form_data = [
('csrf_token', self.csrf_token),
('_method', 'put'),
('user', username),
('users_group_name', 'changed_name'),
('users_group_active', expected_active_state),
('user_group_description', 'changed_description'),
('__start__', 'user_group_members:sequence'),
('__start__', 'member:mapping'),
('member_user_id', uid),
('type', 'existing'),
('__end__', 'member:mapping'),
('__end__', 'user_group_members:sequence'),
]
ugid = user_group.users_group_id
self.app.post(url('update_users_group', user_group_id=ugid), form_data)
user_group = UserGroup.get(ugid)
assert user_group
assert user_group.members[0].user_id == uid
assert user_group.user_id == uid
assert 'changed_name' in user_group.users_group_name
assert 'changed_description' in user_group.user_group_description
assert user_group.users_group_active == expected_active_state
fixture.destroy_user_group(user_group)

View file

@ -113,40 +113,20 @@ def test_add_and_remove_user_from_group(user_regular, user_util):
assert user_group.members == []
@pytest.mark.parametrize(
'data, expected', [
("1", [1]), (["1", "2"], [1, 2])
]
)
@pytest.mark.parametrize('data, expected', [
([], []),
([{"member_user_id": 1, "type": "new"}], [1]),
([{"member_user_id": 1, "type": "new"},
{"member_user_id": 1, "type": "existing"}], [1]),
([{"member_user_id": 1, "type": "new"},
{"member_user_id": 2, "type": "new"},
{"member_user_id": 3, "type": "remove"}], [1, 2])
])
def test_clean_members_data(data, expected):
cleaned = UserGroupModel()._clean_members_data(data)
assert cleaned == expected
def test_update_members_from_user_ids(user_regular, user_util):
user_group = user_util.create_user_group()
assert user_group.members == []
assert user_group.user != user_regular
expected_active_state = not user_group.users_group_active
form_data = {
'users_group_members': str(user_regular.user_id),
'user': str(user_regular.username),
'users_group_name': 'changed_name',
'users_group_active': expected_active_state,
'user_group_description': 'changed_description'
}
UserGroupModel().update(user_group, form_data)
assert user_group.members[0].user_id == user_regular.user_id
assert user_group.user_id == user_regular.user_id
assert 'changed_name' in user_group.users_group_name
assert 'changed_description' in user_group.user_group_description
assert user_group.users_group_active == expected_active_state
# Ignore changes on the test
Session().rollback()
def _create_test_members():
members = []
for member_number in range(3):

View file

@ -38,6 +38,7 @@ import requests
from webtest.app import TestApp
import rhodecode
from rhodecode.lib.utils2 import AttributeDict
from rhodecode.model.changeset_status import ChangesetStatusModel
from rhodecode.model.comment import ChangesetCommentsModel
from rhodecode.model.db import (