FEATURE: Allow admins to pre-populate user fields (#12361)

Admins can use bulk invites to pre-populate user fields. The imported
CSV file must have a header with "email" column (first position) and
names of the user fields (exact match).

Under the hood, the bulk invite will create staged users and populate
the user fields of those.
This commit is contained in:
Dan Ungureanu 2021-03-29 14:03:19 +03:00 committed by GitHub
parent 3c53d4d2d8
commit 8335c8dc1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 174 additions and 52 deletions

View File

@ -17,4 +17,16 @@ export default DiscourseRoute.extend({
return {}; return {};
} }
}, },
setupController(controller, model) {
this._super(...arguments);
if (model.user_fields) {
controller.userFields.forEach((userField) => {
if (model.user_fields[userField.field.id]) {
userField.value = model.user_fields[userField.field.id];
}
});
}
},
}); });

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'csv'
class InvitesController < ApplicationController class InvitesController < ApplicationController
requires_login only: [:create, :destroy, :destroy_all_expired, :resend_invite, :resend_all_invites, :upload_csv] requires_login only: [:create, :destroy, :destroy_all_expired, :resend_invite, :resend_all_invites, :upload_csv]
@ -29,13 +31,19 @@ class InvitesController < ApplicationController
hidden_email = email != invite.email hidden_email = email != invite.email
store_preloaded("invite_info", MultiJson.dump( info = {
invited_by: UserNameSerializer.new(invite.invited_by, scope: guardian, root: false), invited_by: UserNameSerializer.new(invite.invited_by, scope: guardian, root: false),
email: email, email: email,
hidden_email: hidden_email, hidden_email: hidden_email,
username: hidden_email ? '' : UserNameSuggester.suggest(invite.email), username: hidden_email ? '' : UserNameSuggester.suggest(invite.email),
is_invite_link: invite.is_invite_link? is_invite_link: invite.is_invite_link?
)) }
if staged_user = User.where(staged: true).with_email(invite.email).first
info[:user_fields] = staged_user.user_fields
end
store_preloaded("invite_info", MultiJson.dump(info))
secure_session["invite-key"] = invite.invite_key secure_session["invite-key"] = invite.invite_key
@ -266,35 +274,44 @@ class InvitesController < ApplicationController
end end
def upload_csv def upload_csv
require 'csv'
guardian.ensure_can_bulk_invite_to_forum!(current_user) guardian.ensure_can_bulk_invite_to_forum!(current_user)
hijack do hijack do
begin begin
file = params[:file] || params[:files].first file = params[:file] || params[:files].first
count = 0 csv_header = nil
invites = [] invites = []
max_bulk_invites = SiteSetting.max_bulk_invites
CSV.foreach(file.tempfile) do |row| CSV.foreach(file.tempfile, encoding: "bom|utf-8") do |row|
count += 1 # Try to extract a CSV header, if it exists
invites.push(email: row[0], groups: row[1], topic_id: row[2]) if row[0].present? if csv_header.nil?
break if count >= max_bulk_invites if row[0] == 'email'
csv_header = row
next
else
csv_header = ["email", "groups", "topic_id"]
end
end
if row[0].present?
invites.push(csv_header.zip(row).map.to_h.filter { |k, v| v.present? })
end
break if invites.count >= SiteSetting.max_bulk_invites
end end
if invites.present? if invites.present?
Jobs.enqueue(:bulk_invite, invites: invites, current_user_id: current_user.id) Jobs.enqueue(:bulk_invite, invites: invites, current_user_id: current_user.id)
if count >= max_bulk_invites
render json: failed_json.merge(errors: [I18n.t("bulk_invite.max_rows", max_bulk_invites: max_bulk_invites)]), status: 422 if invites.count >= SiteSetting.max_bulk_invites
render json: failed_json.merge(errors: [I18n.t("bulk_invite.max_rows", max_bulk_invites: SiteSetting.max_bulk_invites)]), status: 422
else else
render json: success_json render json: success_json
end end
else else
render json: failed_json.merge(errors: [I18n.t("bulk_invite.error")]), status: 422 render json: failed_json.merge(errors: [I18n.t("bulk_invite.error")]), status: 422
end end
rescue
render json: failed_json.merge(errors: [I18n.t("bulk_invite.error")]), status: 422
end end
end end
end end

View File

@ -6,25 +6,27 @@ module Jobs
def initialize def initialize
super super
@logs = []
@sent = 0 @logs = []
@failed = 0 @sent = 0
@groups = {} @failed = 0
@groups = {}
@user_fields = {}
@valid_groups = {} @valid_groups = {}
end end
def execute(args) def execute(args)
invites = args[:invites] @invites = args[:invites]
raise Discourse::InvalidParameters.new(:invites) if invites.blank? raise Discourse::InvalidParameters.new(:invites) if @invites.blank?
@current_user = User.find_by(id: args[:current_user_id]) @current_user = User.find_by(id: args[:current_user_id])
raise Discourse::InvalidParameters.new(:current_user_id) unless @current_user raise Discourse::InvalidParameters.new(:current_user_id) unless @current_user
@guardian = Guardian.new(@current_user) @guardian = Guardian.new(@current_user)
@total_invites = invites.length
process_invites(invites) process_invites(@invites)
if @total_invites > Invite::BULK_INVITE_EMAIL_LIMIT if @invites.length > Invite::BULK_INVITE_EMAIL_LIMIT
::Jobs.enqueue(:process_bulk_invite_emails) ::Jobs.enqueue(:process_bulk_invite_emails)
end end
ensure ensure
@ -87,10 +89,22 @@ module Jobs
topic topic
end end
def get_user_fields(fields)
user_fields = {}
fields.each do |key, value|
@user_fields[key] ||= UserField.find_by(name: key)&.id || :nil
user_fields[@user_fields[key]] = value if @user_fields[key] != :nil
end
user_fields
end
def send_invite(invite) def send_invite(invite)
email = invite[:email] email = invite[:email]
groups = get_groups(invite[:groups]) groups = get_groups(invite[:groups])
topic = get_topic(invite[:topic_id]) topic = get_topic(invite[:topic_id])
user_fields = get_user_fields(invite.except(:email, :groups, :topic_id))
begin begin
if user = Invite.find_user_by_email(email) if user = Invite.find_user_by_email(email)
@ -105,17 +119,34 @@ module Jobs
end end
end end
end end
else
if @total_invites > Invite::BULK_INVITE_EMAIL_LIMIT if user_fields.present?
invite = Invite.generate(@current_user, user_fields.each do |user_field, value|
email: email, user.set_user_field(user_field, value)
topic: topic, end
group_ids: groups.map(&:id), user.save_custom_fields
emailed_status: Invite.emailed_status_types[:bulk_pending]
)
else
Invite.generate(@current_user, email: email, topic: topic, group_ids: groups.map(&:id))
end end
else
if user_fields.present?
user = User.where(staged: true).find_by_email(email)
user ||= User.new(username: UserNameSuggester.suggest(email), email: email, staged: true)
user_fields.each do |user_field, value|
user.set_user_field(user_field, value)
end
user.save!
end
invite_opts = {
email: email,
topic: topic,
group_ids: groups.map(&:id),
}
if @invites.length > Invite::BULK_INVITE_EMAIL_LIMIT
invite_opts[:emailed_status] = Invite.emailed_status_types[:bulk_pending]
end
Invite.generate(@current_user, invite_opts)
end end
rescue => e rescue => e
save_log "Error inviting '#{email}' -- #{Rails::Html::FullSanitizer.new.sanitize(e.message)}" save_log "Error inviting '#{email}' -- #{Rails::Html::FullSanitizer.new.sanitize(e.message)}"
@ -153,7 +184,7 @@ module Jobs
group = @groups[group_name] group = @groups[group_name]
unless group unless group
group = Group.find_by("lower(name) = ?", group_name) group = Group.find_by('lower(name) = ?', group_name)
@groups[group_name] = group @groups[group_name] = group
end end

View File

@ -1173,6 +1173,10 @@ class User < ActiveRecord::Base
end end
end end
def set_user_field(field_id, value)
custom_fields["#{USER_FIELD_PREFIX}#{field_id}"] = value
end
def number_of_deleted_posts def number_of_deleted_posts
Post.with_deleted Post.with_deleted
.where(user_id: self.id) .where(user_id: self.id)

View File

@ -0,0 +1,3 @@
email,groups,location
test@example.com,discourse;ubuntu,usa
test2@example.com,discourse;ubuntu,europe
1 email groups location
2 test@example.com discourse;ubuntu usa
3 test2@example.com discourse;ubuntu europe

View File

@ -10,7 +10,7 @@ describe Jobs::BulkInvite do
fab!(:group2) { Fabricate(:group, name: 'group2') } fab!(:group2) { Fabricate(:group, name: 'group2') }
fab!(:topic) { Fabricate(:topic) } fab!(:topic) { Fabricate(:topic) }
let(:staged_user) { Fabricate(:user, staged: true, active: false) } let(:staged_user) { Fabricate(:user, staged: true, active: false) }
let(:email) { "test@discourse.org" } let(:email) { 'test@discourse.org' }
let(:invites) { [{ email: staged_user.email }, { email: 'test2@discourse.org' }, { email: 'test@discourse.org', groups: 'GROUP1;group2', topic_id: topic.id }] } let(:invites) { [{ email: staged_user.email }, { email: 'test2@discourse.org' }, { email: 'test@discourse.org', groups: 'GROUP1;group2', topic_id: topic.id }] }
it 'raises an error when the invites array is missing' do it 'raises an error when the invites array is missing' do
@ -30,13 +30,11 @@ describe Jobs::BulkInvite do
) )
expect(Invite.exists?(email: staged_user.email)).to eq(true) expect(Invite.exists?(email: staged_user.email)).to eq(true)
expect(Invite.exists?(email: "test2@discourse.org")).to eq(true) expect(Invite.exists?(email: 'test2@discourse.org')).to eq(true)
invite = Invite.last invite = Invite.last
expect(invite.email).to eq(email) expect(invite.email).to eq(email)
expect(invite.invited_groups.pluck(:group_id)).to contain_exactly( expect(invite.invited_groups.pluck(:group_id)).to contain_exactly(group1.id, group2.id)
group1.id, group2.id
)
expect(invite.topic_invites.pluck(:topic_id)).to contain_exactly(topic.id) expect(invite.topic_invites.pluck(:topic_id)).to contain_exactly(topic.id)
end end
@ -49,12 +47,8 @@ describe Jobs::BulkInvite do
) )
invite = Invite.last invite = Invite.last
expect(invite.email).to eq(email) expect(invite.email).to eq(email)
expect(invite.invited_groups.pluck(:group_id)).to contain_exactly(group1.id)
expect(invite.invited_groups.pluck(:group_id)).to contain_exactly(
group1.id
)
end end
it 'does not create invited groups record if the user can not manage the group' do it 'does not create invited groups record if the user can not manage the group' do
@ -66,16 +60,12 @@ describe Jobs::BulkInvite do
) )
invite = Invite.last invite = Invite.last
expect(invite.email).to eq(email) expect(invite.email).to eq(email)
expect(invite.invited_groups.pluck(:group_id)).to contain_exactly(group1.id)
expect(invite.invited_groups.pluck(:group_id)).to contain_exactly(
group1.id
)
end end
it 'adds existing users to valid groups' do it 'adds existing users to valid groups' do
existing_user = Fabricate(:user, email: "test@discourse.org") existing_user = Fabricate(:user, email: 'test@discourse.org')
group2.update!(automatic: true) group2.update!(automatic: true)
@ -87,10 +77,30 @@ describe Jobs::BulkInvite do
end.to change { Invite.count }.by(2) end.to change { Invite.count }.by(2)
expect(Invite.exists?(email: staged_user.email)).to eq(true) expect(Invite.exists?(email: staged_user.email)).to eq(true)
expect(Invite.exists?(email: "test2@discourse.org")).to eq(true) expect(Invite.exists?(email: 'test2@discourse.org')).to eq(true)
expect(existing_user.reload.groups).to eq([group1]) expect(existing_user.reload.groups).to eq([group1])
end end
it 'can create staged users and prepulate user fields' do
user_field = Fabricate(:user_field)
described_class.new.execute(
current_user_id: admin.id,
invites: [
{ email: 'test@discourse.org' }, # new user without user fields
{ email: user.email, user_field.name => 'value 1' }, # existing user with user fields
{ email: staged_user.email, user_field.name => 'value 2' }, # existing staged user with user fields
{ email: 'test2@discourse.org', user_field.name => 'value 3' } # new staged user with user fields
]
)
expect(Invite.count).to eq(3)
expect(User.where(staged: true).find_by_email('test@discourse.org')).to eq(nil)
expect(user.user_fields[user_field.id.to_s]).to eq('value 1')
expect(staged_user.user_fields[user_field.id.to_s]).to eq('value 2')
new_staged_user = User.where(staged: true).find_by_email('test2@discourse.org')
expect(new_staged_user.user_fields[user_field.id.to_s]).to eq('value 3')
end
context 'invites are more than 200' do context 'invites are more than 200' do
let(:bulk_invites) { [] } let(:bulk_invites) { [] }
@ -107,7 +117,7 @@ describe Jobs::BulkInvite do
) )
invite = Invite.last invite = Invite.last
expect(invite.email).to eq("test_201@discourse.org") expect(invite.email).to eq('test_201@discourse.org')
expect(invite.emailed_status).to eq(Invite.emailed_status_types[:bulk_pending]) expect(invite.emailed_status).to eq(Invite.emailed_status_types[:bulk_pending])
expect(Jobs::ProcessBulkInviteEmails.jobs.size).to eq(1) expect(Jobs::ProcessBulkInviteEmails.jobs.size).to eq(1)
end end

View File

@ -176,6 +176,16 @@ describe Invite do
expect(invite.redeem).to be_blank expect(invite.redeem).to be_blank
end end
it 'keeps custom fields' do
user_field = Fabricate(:user_field)
staged_user = Fabricate(:user, staged: true, email: invite.email)
staged_user.set_user_field(user_field.id, 'some value')
staged_user.save_custom_fields
expect(invite.redeem).to eq(staged_user)
expect(staged_user.reload.user_fields[user_field.id.to_s]).to eq('some value')
end
it 'creates a notification for the invitee' do it 'creates a notification for the invitee' do
expect { invite.redeem }.to change { Notification.count } expect { invite.redeem }.to change { Notification.count }
end end

View File

@ -27,6 +27,20 @@ describe InvitesController do
expect(response.body).not_to include('i*****g@a***********e.ooo') expect(response.body).not_to include('i*****g@a***********e.ooo')
end end
it 'shows default user fields' do
user_field = Fabricate(:user_field)
staged_user = Fabricate(:user, staged: true, email: invite.email)
staged_user.set_user_field(user_field.id, 'some value')
staged_user.save_custom_fields
get "/invites/#{invite.invite_key}"
expect(response.body).to have_tag("div#data-preloaded") do |element|
json = JSON.parse(element.current_scope.attribute('data-preloaded').value)
invite_info = JSON.parse(json['invite_info'])
expect(invite_info['user_fields'][user_field.id.to_s]).to eq('some value')
end
end
it 'fails for logged in users' do it 'fails for logged in users' do
sign_in(Fabricate(:user)) sign_in(Fabricate(:user))
@ -691,6 +705,9 @@ describe InvitesController do
let(:csv_file) { File.new("#{Rails.root}/spec/fixtures/csv/discourse.csv") } let(:csv_file) { File.new("#{Rails.root}/spec/fixtures/csv/discourse.csv") }
let(:file) { Rack::Test::UploadedFile.new(File.open(csv_file)) } let(:file) { Rack::Test::UploadedFile.new(File.open(csv_file)) }
let(:csv_file_with_headers) { File.new("#{Rails.root}/spec/fixtures/csv/discourse_headers.csv") }
let(:file_with_headers) { Rack::Test::UploadedFile.new(File.open(csv_file_with_headers)) }
it 'fails if you cannot bulk invite to the forum' do it 'fails if you cannot bulk invite to the forum' do
sign_in(Fabricate(:user)) sign_in(Fabricate(:user))
post '/invites/upload_csv.json', params: { file: file, name: 'discourse.csv' } post '/invites/upload_csv.json', params: { file: file, name: 'discourse.csv' }
@ -713,6 +730,24 @@ describe InvitesController do
expect(Jobs::BulkInvite.jobs.size).to eq(1) expect(Jobs::BulkInvite.jobs.size).to eq(1)
expect(response.parsed_body['errors'][0]).to eq(I18n.t('bulk_invite.max_rows', max_bulk_invites: SiteSetting.max_bulk_invites)) expect(response.parsed_body['errors'][0]).to eq(I18n.t('bulk_invite.max_rows', max_bulk_invites: SiteSetting.max_bulk_invites))
end end
it 'can import user fields' do
Jobs.run_immediately!
user_field = Fabricate(:user_field, name: "location")
Fabricate(:group, name: 'discourse')
Fabricate(:group, name: 'ubuntu')
sign_in(admin)
post '/invites/upload_csv.json', params: { file: file_with_headers, name: 'discourse_headers.csv' }
expect(response.status).to eq(200)
user = User.where(staged: true).find_by_email('test@example.com')
expect(user.user_fields[user_field.id.to_s]).to eq('usa')
user2 = User.where(staged: true).find_by_email('test2@example.com')
expect(user2.user_fields[user_field.id.to_s]).to eq('europe')
end
end end
end end
end end