diff --git a/app/assets/javascripts/admin/controllers/admin-groups-bulk.js.es6 b/app/assets/javascripts/admin/controllers/admin-groups-bulk.js.es6
new file mode 100644
index 000000000..6628a6fa7
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-groups-bulk.js.es6
@@ -0,0 +1,34 @@
+import computed from 'ember-addons/ember-computed-decorators';
+import { popupAjaxError } from 'discourse/lib/ajax-error';
+
+export default Ember.Controller.extend({
+ users: null,
+ groupId: null,
+ saving: false,
+
+ @computed('saving', 'users', 'groupId')
+ buttonDisabled(saving, users, groupId) {
+ return saving || !groupId || !users || !users.length;
+ },
+
+ actions: {
+ addToGroup() {
+ if (this.get('saving')) { return; }
+
+ const users = this.get('users').split("\n")
+ .uniq()
+ .reject(x => x.length === 0);
+
+ this.set('saving', true);
+ Discourse.ajax('/admin/groups/bulk', {
+ data: { users, group_id: this.get('groupId') },
+ method: 'PUT'
+ }).then(() => {
+ this.transitionToRoute('adminGroups.bulkComplete');
+ }).catch(popupAjaxError).finally(() => {
+ this.set('saving', false);
+ });
+
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/routes/admin-groups-bulk.js.es6 b/app/assets/javascripts/admin/routes/admin-groups-bulk.js.es6
new file mode 100644
index 000000000..8d9554556
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-groups-bulk.js.es6
@@ -0,0 +1,13 @@
+import Group from 'discourse/models/group';
+
+export default Ember.Route.extend({
+ model() {
+ return Group.findAll().then(groups => {
+ return groups.filter(g => !g.get('automatic'));
+ });
+ },
+
+ setupController(controller, groups) {
+ controller.setProperties({ groups, groupId: null, users: null });
+ }
+});
diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
index e01d0f8f0..dd758556c 100644
--- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
@@ -49,6 +49,8 @@ export default {
});
this.resource('adminGroups', { path: '/groups' }, function() {
+ this.route('bulk');
+ this.route('bulkComplete', { path: 'bulk-complete' });
this.resource('adminGroupsType', { path: '/:type' }, function() {
this.resource('adminGroup', { path: '/:name' });
});
diff --git a/app/assets/javascripts/admin/templates/groups-bulk-complete.hbs b/app/assets/javascripts/admin/templates/groups-bulk-complete.hbs
new file mode 100644
index 000000000..51eb3e439
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/groups-bulk-complete.hbs
@@ -0,0 +1 @@
+
{{i18n "admin.groups.bulk_complete"}}
diff --git a/app/assets/javascripts/admin/templates/groups-bulk.hbs b/app/assets/javascripts/admin/templates/groups-bulk.hbs
new file mode 100644
index 000000000..baf3a63cd
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/groups-bulk.hbs
@@ -0,0 +1,19 @@
+
+
{{i18n "admin.groups.bulk_paste"}}
+
+
+ {{textarea value=users class="paste-users"}}
+
+
+
+ {{combo-box content=groups valueAttribute="id" value=groupId none="admin.groups.bulk_select"}}
+
+
+
+ {{d-button disabled=buttonDisabled
+ class="btn-primary"
+ action="addToGroup"
+ icon="plus"
+ label="admin.groups.bulk"}}
+
+
diff --git a/app/assets/javascripts/admin/templates/groups.hbs b/app/assets/javascripts/admin/templates/groups.hbs
index 2d767c384..aa7d9213c 100644
--- a/app/assets/javascripts/admin/templates/groups.hbs
+++ b/app/assets/javascripts/admin/templates/groups.hbs
@@ -1,6 +1,7 @@
{{#admin-nav}}
{{nav-item route='adminGroupsType' routeParam='custom' label='admin.groups.custom'}}
{{nav-item route='adminGroupsType' routeParam='automatic' label='admin.groups.automatic'}}
+ {{nav-item route='adminGroups.bulk' label='admin.groups.bulk'}}
{{/admin-nav}}
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index 60396122e..63c01ffed 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -215,6 +215,11 @@ td.flaggers td {
}
}
+.paste-users {
+ width: 400px;
+ height: 150px;
+}
+
.groups, .badges {
.form-horizontal {
label {
@@ -1015,6 +1020,11 @@ table.api-keys {
}
}
+.groups-bulk {
+ .control {
+ margin-bottom: 1em;
+ }
+}
.commits-widget {
border: solid 1px dark-light-diff($primary, $secondary, 90%, -60%);
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 767657f1e..67e8d8696 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -19,6 +19,42 @@ class Admin::GroupsController < Admin::AdminController
render nothing: true
end
+ def bulk
+ render nothing: true
+ end
+
+ def bulk_perform
+ group = Group.find(params[:group_id].to_i)
+ if group.present?
+ users = (params[:users] || []).map {|u| u.downcase}
+ user_ids = User.where("username_lower in (:users) OR email IN (:users)", users: users).pluck(:id)
+
+ if user_ids.present?
+ Group.exec_sql("INSERT INTO group_users
+ (group_id, user_id, created_at, updated_at)
+ SELECT #{group.id},
+ u.id,
+ CURRENT_TIMESTAMP,
+ CURRENT_TIMESTAMP
+ FROM users AS u
+ WHERE u.id IN (#{user_ids.join(', ')})
+ AND NOT EXISTS(SELECT 1 FROM group_users AS gu
+ WHERE gu.user_id = u.id AND
+ gu.group_id = #{group.id})")
+
+ if group.primary_group?
+ User.where(id: user_ids).update_all(primary_group_id: group.id)
+ end
+
+ if group.title.present?
+ User.where(id: user_ids).update_all(title: group.title)
+ end
+ end
+ end
+
+ render json: success_json
+ end
+
def create
group = Group.new
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index af5d2e6e0..6eff38ce5 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1949,6 +1949,10 @@ en:
add: "Add"
add_members: "Add members"
custom: "Custom"
+ bulk_complete: "The users have been added to the group."
+ bulk: "Bulk Add to Group"
+ bulk_paste: "Paste a list of usernames or emails, one per line:"
+ bulk_select: "(select a group)"
automatic: "Automatic"
automatic_membership_email_domains: "Users who register with an email domain that exactly matches one in this list will be automatically added to this group:"
automatic_membership_retroactive: "Apply the same email domain rule to add existing registered users"
diff --git a/config/routes.rb b/config/routes.rb
index cc55476b0..0af5799a2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -60,6 +60,9 @@ Discourse::Application.routes.draw do
resources :groups, constraints: AdminConstraint.new do
collection do
post "refresh_automatic_groups" => "groups#refresh_automatic_groups"
+ get 'bulk'
+ get 'bulk-complete' => 'groups#bulk'
+ put 'bulk' => 'groups#bulk_perform'
end
member do
put "members" => "groups#add_members"
diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb
index 75bc9ddac..e7c2233ec 100644
--- a/spec/controllers/admin/groups_controller_spec.rb
+++ b/spec/controllers/admin/groups_controller_spec.rb
@@ -36,6 +36,26 @@ describe Admin::GroupsController do
end
+ context ".bulk" do
+ it "can assign users to a group by email or username" do
+ group = Fabricate(:group, name: "test", primary_group: true, title: 'WAT')
+ user = Fabricate(:user)
+ user2 = Fabricate(:user)
+
+ xhr :put, :bulk_perform, group_id: group.id, users: [user.username.upcase, user2.email, 'doesnt_exist']
+
+ expect(response).to be_success
+
+ user.reload
+ expect(user.primary_group).to eq(group)
+ expect(user.title).to eq("WAT")
+
+ user2.reload
+ expect(user2.primary_group).to eq(group)
+
+ end
+ end
+
context ".create" do
it "strip spaces on the group name" do