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

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.

diff --git a/app/assets/javascripts/discourse/app/routes/invites-show.js b/app/assets/javascripts/discourse/app/routes/invites-show.js
index d094036..7a044a0 100644
--- a/app/assets/javascripts/discourse/app/routes/invites-show.js
+++ b/app/assets/javascripts/discourse/app/routes/invites-show.js
@@ -17,4 +17,16 @@ export default DiscourseRoute.extend({
       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];
+        }
+      });
+    }
+  },
 });
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 0e6a2b4..f6b8945 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -1,5 +1,7 @@
 # frozen_string_literal: true
 
+require 'csv'
+
 class InvitesController < ApplicationController
 
   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
 
-      store_preloaded("invite_info", MultiJson.dump(
+      info = {
         invited_by: UserNameSerializer.new(invite.invited_by, scope: guardian, root: false),
         email: email,
         hidden_email: hidden_email,
         username: hidden_email ? '' : UserNameSuggester.suggest(invite.email),
         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
 
@@ -266,35 +274,44 @@ class InvitesController < ApplicationController
   end
 
   def upload_csv
-    require 'csv'
-
     guardian.ensure_can_bulk_invite_to_forum!(current_user)
 
     hijack do
       begin
         file = params[:file] || params[:files].first
 
-        count = 0
+        csv_header = nil
         invites = []
-        max_bulk_invites = SiteSetting.max_bulk_invites
-        CSV.foreach(file.tempfile) do |row|
-          count += 1
-          invites.push(email: row[0], groups: row[1], topic_id: row[2]) if row[0].present?
-          break if count >= max_bulk_invites
+
+        CSV.foreach(file.tempfile, encoding: "bom|utf-8") do |row|
+          # Try to extract a CSV header, if it exists
+          if csv_header.nil?
+            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
 
         if invites.present?
           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
             render json: success_json
           end
         else
           render json: failed_json.merge(errors: [I18n.t("bulk_invite.error")]), status: 422
         end
-      rescue
-        render json: failed_json.merge(errors: [I18n.t("bulk_invite.error")]), status: 422
       end
     end
   end
diff --git a/app/jobs/regular/bulk_invite.rb b/app/jobs/regular/bulk_invite.rb
index 2965abc..cb9b058 100644
--- a/app/jobs/regular/bulk_invite.rb
+++ b/app/jobs/regular/bulk_invite.rb
@@ -6,25 +6,27 @@ module Jobs
 
     def initialize
       super
-      @logs    = []
-      @sent    = 0
-      @failed  = 0
-      @groups = {}
+
+      @logs         = []
+      @sent         = 0
+      @failed       = 0
+      @groups       = {}
+      @user_fields  = {}
       @valid_groups = {}
     end
 
     def execute(args)
-      invites = args[:invites]
-      raise Discourse::InvalidParameters.new(:invites) if invites.blank?
+      @invites = args[:invites]
+      raise Discourse::InvalidParameters.new(:invites) if @invites.blank?
 
       @current_user = User.find_by(id: args[:current_user_id])
       raise Discourse::InvalidParameters.new(:current_user_id) unless @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)
       end
     ensure
@@ -87,10 +89,22 @@ module Jobs
       topic
     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)
       email = invite[:email]
       groups = get_groups(invite[:groups])
       topic = get_topic(invite[:topic_id])
+      user_fields = get_user_fields(invite.except(:email, :groups, :topic_id))
 
       begin
         if user = Invite.find_user_by_email(email)
@@ -105,17 +119,34 @@ module Jobs
               end
             end
           end
+
+          if user_fields.present?
+            user_fields.each do |user_field, value|
+              user.set_user_field(user_field, value)
+            end
+            user.save_custom_fields
+          end
         else
-          if @total_invites > Invite::BULK_INVITE_EMAIL_LIMIT
-            invite = Invite.generate(@current_user,
-              email: email,
-              topic: topic,
-              group_ids: groups.map(&:id),
-              emailed_status: Invite.emailed_status_types[:bulk_pending]
-            )
-          else
-            Invite.generate(@current_user, email: email, topic: topic, group_ids: groups.map(&:id))
+          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
       rescue => e
         save_log "Error inviting '#{email}' -- #{Rails::Html::FullSanitizer.new.sanitize(e.message)}"
@@ -153,7 +184,7 @@ module Jobs
       group = @groups[group_name]
 
       unless group
-        group = Group.find_by("lower(name) = ?", group_name)
+        group = Group.find_by('lower(name) = ?', group_name)
         @groups[group_name] = group
       end
 
diff --git a/app/models/user.rb b/app/models/user.rb
index d8cbdc3..349cedf 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1173,6 +1173,10 @@ class User < ActiveRecord::Base
     end
   end
 
+  def set_user_field(field_id, value)
+    custom_fields["#{USER_FIELD_PREFIX}#{field_id}"] = value
+  end
+
   def number_of_deleted_posts

[... diff too long, it was truncated ...]

GitHub sha: 8335c8dc

This commit appears in #12361 which was approved by eviltrout. It was merged by udan11.