diff --git a/app/assets/javascripts/admin/controllers/admin-badges-award.js.es6 b/app/assets/javascripts/admin/controllers/admin-badges-award.js.es6
new file mode 100644
index 0000000000..af3afd528b
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-badges-award.js.es6
@@ -0,0 +1,35 @@
+import Controller from "@ember/controller";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+
+export default Controller.extend({
+ saving: false,
+
+ actions: {
+ massAward() {
+ const file = document.querySelector("#massAwardCSVUpload").files[0];
+
+ if (this.model && file) {
+ const options = {
+ type: "POST",
+ processData: false,
+ contentType: false,
+ data: new FormData()
+ };
+
+ options.data.append("file", file);
+
+ this.set("saving", true);
+
+ ajax(`/admin/badges/award/${this.model.id}`, options)
+ .then(() => {
+ bootbox.alert(I18n.t("admin.badges.mass_award.success"));
+ })
+ .catch(popupAjaxError)
+ .finally(() => this.set("saving", false));
+ } else {
+ bootbox.alert(I18n.t("admin.badges.mass_award.aborted"));
+ }
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/controllers/admin-badges.js.es6 b/app/assets/javascripts/admin/controllers/admin-badges.js.es6
index cf6c4e3aa2..4797fb2695 100644
--- a/app/assets/javascripts/admin/controllers/admin-badges.js.es6
+++ b/app/assets/javascripts/admin/controllers/admin-badges.js.es6
@@ -1,2 +1,18 @@
import Controller from "@ember/controller";
-export default Controller.extend();
+import { inject as service } from "@ember/service";
+import discourseComputed from "discourse-common/utils/decorators";
+
+export default Controller.extend({
+ routing: service("-routing"),
+
+ @discourseComputed("routing.currentRouteName")
+ selectedRoute() {
+ const currentRoute = this.routing.currentRouteName;
+ const indexRoute = "adminBadges.index";
+ if (currentRoute === indexRoute) {
+ return "adminBadges.show";
+ } else {
+ return this.routing.currentRouteName;
+ }
+ }
+});
diff --git a/app/assets/javascripts/admin/routes/admin-badges-award.js.es6 b/app/assets/javascripts/admin/routes/admin-badges-award.js.es6
new file mode 100644
index 0000000000..90a4cba17f
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-badges-award.js.es6
@@ -0,0 +1,12 @@
+import Route from "discourse/routes/discourse";
+
+export default Route.extend({
+ model(params) {
+ if (params.badge_id !== "new") {
+ return this.modelFor("adminBadges").findBy(
+ "id",
+ parseInt(params.badge_id, 10)
+ );
+ }
+ }
+});
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 f90b66f97a..8297380e05 100644
--- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
@@ -190,6 +190,7 @@ export default function() {
"adminBadges",
{ path: "/badges", resetNamespace: true },
function() {
+ this.route("award", { path: "/award/:badge_id" });
this.route("show", { path: "/:badge_id" });
}
);
diff --git a/app/assets/javascripts/admin/templates/badges-award.hbs b/app/assets/javascripts/admin/templates/badges-award.hbs
new file mode 100644
index 0000000000..cf60ff513f
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/badges-award.hbs
@@ -0,0 +1,22 @@
+{{#d-section class="award-badge"}}
+
+{{/d-section}}
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/templates/badges.hbs b/app/assets/javascripts/admin/templates/badges.hbs
index 398a5c08fc..e1ade47b01 100644
--- a/app/assets/javascripts/admin/templates/badges.hbs
+++ b/app/assets/javascripts/admin/templates/badges.hbs
@@ -6,13 +6,18 @@
{{d-icon "plus"}}
{{i18n 'admin.badges.new'}}
{{/link-to}}
+
+ {{#link-to 'adminBadges.award' 'new' class="btn btn-primary"}}
+ {{d-icon "certificate"}}
+ {{i18n 'admin.badges.mass_award.button'}}
+ {{/link-to}}
{{#each model as |badge|}}
-
- {{#link-to 'adminBadges.show' badge.id}}
+ {{#link-to selectedRoute badge.id}}
{{badge-button badge=badge}}
{{#if badge.newBadge}}
{{i18n 'filters.new.lower_title'}}
diff --git a/app/assets/stylesheets/common/admin/badges.scss b/app/assets/stylesheets/common/admin/badges.scss
index 57df3636bd..c1f1bd43d1 100644
--- a/app/assets/stylesheets/common/admin/badges.scss
+++ b/app/assets/stylesheets/common/admin/badges.scss
@@ -119,6 +119,36 @@
}
}
+.award-badge {
+ margin: 15px 0 0 15px;
+ float: left;
+
+ .badge-preview {
+ min-height: 110px;
+ max-width: 300px;
+ display: flex;
+ align-items: center;
+ background-color: $primary-very-low;
+ border: 1px solid $primary-low;
+ padding: 0 10px 0 10px;
+
+ img,
+ svg {
+ width: 60px;
+ height: 60px;
+ }
+
+ .badge-display-name {
+ margin-left: 5px;
+ }
+
+ .badge-placeholder {
+ width: 100%;
+ text-align: center;
+ }
+ }
+}
+
// badge-grouping modal
.badge-groupings-modal {
.badge-groupings {
diff --git a/app/controllers/admin/badges_controller.rb b/app/controllers/admin/badges_controller.rb
index fe67475e26..0f310d9a9b 100644
--- a/app/controllers/admin/badges_controller.rb
+++ b/app/controllers/admin/badges_controller.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require 'csv'
+
class Admin::BadgesController < Admin::AdminController
def index
@@ -33,6 +35,38 @@ class Admin::BadgesController < Admin::AdminController
def show
end
+ def award
+ end
+
+ def mass_award
+ csv_file = params.permit(:file).fetch(:file, nil)
+ badge = Badge.find_by(id: params[:badge_id])
+ raise Discourse::InvalidParameters if csv_file.try(:tempfile).nil? || badge.nil?
+
+ batch_number = 1
+ batch = []
+
+ File.open(csv_file) do |csv|
+ csv.each_line do |email_line|
+ batch.concat CSV.parse_line(email_line)
+
+ # Split the emails in batches of 200 elements.
+ full_batch = csv.lineno % (BadgeGranter::MAX_ITEMS_FOR_DELTA * batch_number) == 0
+ last_batch_item = full_batch || csv.eof?
+
+ if last_batch_item
+ Jobs.enqueue(:mass_award_badge, user_emails: batch, badge_id: badge.id)
+ batch = []
+ batch_number += 1
+ end
+ end
+ end
+
+ head :ok
+ rescue CSV::MalformedCSVError
+ raise Discourse::InvalidParameters
+ end
+
def badge_types
badge_types = BadgeType.all.to_a
render_serialized(badge_types, BadgeTypeSerializer, root: "badge_types")
diff --git a/app/jobs/regular/mass_award_badge.rb b/app/jobs/regular/mass_award_badge.rb
new file mode 100644
index 0000000000..ae6db5f6f5
--- /dev/null
+++ b/app/jobs/regular/mass_award_badge.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Jobs
+ class MassAwardBadge < ::Jobs::Base
+ def execute(args)
+ badge = Badge.find_by(id: args[:badge_id])
+ users = User.select(:id, :username, :locale).with_email(args[:user_emails])
+
+ return if users.empty? || badge.nil?
+
+ BadgeGranter.mass_grant(badge, users)
+ end
+ end
+end
diff --git a/app/services/badge_granter.rb b/app/services/badge_granter.rb
index 25462cf467..783e53fcf6 100644
--- a/app/services/badge_granter.rb
+++ b/app/services/badge_granter.rb
@@ -12,6 +12,25 @@ class BadgeGranter
BadgeGranter.new(badge, user, opts).grant
end
+ def self.mass_grant(badge, users)
+ return unless badge.enabled?
+
+ system_user_id = Discourse.system_user.id
+ user_badges = users.map { |u| { badge_id: badge.id, user_id: u.id, granted_by_id: system_user_id, granted_at: Time.now } }
+ granted_badges = UserBadge.insert_all(user_badges, returning: %i[user_id])
+
+ users.each do |user|
+ notification = send_notification(user.id, user.username, user.locale, badge)
+
+ DB.exec(
+ "UPDATE user_badges SET notification_id = :notification_id WHERE notification_id IS NULL AND user_id = :user_id AND badge_id = :badge_id",
+ notification_id: notification.id,
+ user_id: user.id,
+ badge_id: badge.id
+ )
+ end
+ end
+
def grant
return if @granted_by && !Guardian.new(@granted_by).can_grant_badges?(@user)
return unless @badge.enabled?
@@ -46,17 +65,9 @@ class BadgeGranter
if SiteSetting.enable_badges?
unless @badge.badge_type_id == BadgeType::Bronze && user_badge.granted_at < 2.days.ago
- I18n.with_locale(@user.effective_locale) do
- notification = @user.notifications.create(
- notification_type: Notification.types[:granted_badge],
- data: { badge_id: @badge.id,
- badge_name: @badge.display_name,
- badge_slug: @badge.slug,
- badge_title: @badge.allow_title,
- username: @user.username }.to_json
- )
- user_badge.update notification_id: notification.id
- end
+ notification = self.class.send_notification(@user.id, @user.username, @user.effective_locale, @badge)
+
+ user_badge.update notification_id: notification.id
end
end
end
@@ -331,29 +342,9 @@ class BadgeGranter
# old bronze badges do not matter
next if badge.badge_type_id == BadgeType::Bronze && row.granted_at < 2.days.ago
-
- # Try to use user locale in the badge notification if possible without too much resources
- notification_locale = if SiteSetting.allow_user_locale && row.locale.present?
- row.locale
- else
- SiteSetting.default_locale
- end
-
next if row.staff && badge.awarded_for_trust_level?
- notification = I18n.with_locale(notification_locale) do
- Notification.create!(
- user_id: row.user_id,
- notification_type: Notification.types[:granted_badge],
- data: {
- badge_id: badge.id,
- badge_name: badge.display_name,
- badge_slug: badge.slug,
- badge_title: badge.allow_title,
- username: row.username
- }.to_json
- )
- end
+ notification = send_notification(row.user_id, row.username, row.locale, badge)
DB.exec(
"UPDATE user_badges SET notification_id = :notification_id WHERE id = :id",
@@ -387,4 +378,23 @@ class BadgeGranter
SQL
end
+ def self.send_notification(user_id, username, locale, badge)
+ use_default_locale = !SiteSetting.allow_user_locale && locale.blank?
+ notification_locale = use_default_locale ? SiteSetting.default_locale : locale
+
+ I18n.with_locale(notification_locale) do
+ Notification.create!(
+ user_id: user_id,
+ notification_type: Notification.types[:granted_badge],
+ data: {
+ badge_id: badge.id,
+ badge_name: badge.display_name,
+ badge_slug: badge.slug,
+ badge_title: badge.allow_title,
+ username: username
+ }.to_json
+ )
+ end
+ end
+
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 46ac61fbe1..27721798d9 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -4493,6 +4493,13 @@ en:
title: "Select an existing badge or create a new one to get started"
what_are_badges_title: "What are badges?"
badge_query_examples_title: "Badge query examples"
+ mass_award:
+ button: Award Badge
+ title: Award a badge to a group of users
+ no_badge_selected: No badge selected
+ upload_csv: Upload a CSV with user emails
+ aborted: Be sure you selected the badge you want to award and the csv file containing user emails
+ success: Badge awarding initiated, users will receive the selected badge soon.
emoji:
title: "Emoji"
diff --git a/config/routes.rb b/config/routes.rb
index 266db71595..24a0542109 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -295,6 +295,8 @@ Discourse::Application.routes.draw do
resources :badges, constraints: AdminConstraint.new do
collection do
+ get "/award/:badge_id" => "badges#award"
+ post "/award/:badge_id" => "badges#mass_award"
get "types" => "badges#badge_types"
post "badge_groupings" => "badges#save_badge_groupings"
post "preview" => "badges#preview"
diff --git a/spec/fixtures/csv/user_emails.csv b/spec/fixtures/csv/user_emails.csv
new file mode 100644
index 0000000000..3f13811caa
--- /dev/null
+++ b/spec/fixtures/csv/user_emails.csv
@@ -0,0 +1,4 @@
+user1@test.com
+user2@test.com
+user3@test.com
+user4@test.com
\ No newline at end of file
diff --git a/spec/jobs/mass_award_badge_spec.rb b/spec/jobs/mass_award_badge_spec.rb
new file mode 100644
index 0000000000..67571abee4
--- /dev/null
+++ b/spec/jobs/mass_award_badge_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Jobs::MassAwardBadge do
+ describe '#execute' do
+ fab!(:badge) { Fabricate(:badge) }
+ fab!(:user) { Fabricate(:user) }
+
+ it 'creates the badge for an existing user' do
+ subject.execute(user_emails: [user.email], badge_id: badge.id)
+
+ expect(UserBadge.where(user: user, badge: badge).exists?).to eq(true)
+ end
+
+ it 'works with multiple users' do
+ user_2 = Fabricate(:user)
+
+ subject.execute(user_emails: [user.email, user_2.email], badge_id: badge.id)
+
+ expect(UserBadge.exists?(user: user, badge: badge)).to eq(true)
+ expect(UserBadge.exists?(user: user_2, badge: badge)).to eq(true)
+ end
+
+ it 'also creates a notification for the user' do
+ subject.execute(user_emails: [user.email], badge_id: badge.id)
+
+ expect(Notification.exists?(user: user)).to eq(true)
+ expect(UserBadge.where.not(notification_id: nil).exists?(user: user, badge: badge)).to eq(true)
+ end
+ end
+end
diff --git a/spec/requests/admin/badges_controller_spec.rb b/spec/requests/admin/badges_controller_spec.rb
index 5ea9d5fd14..15d27bf2fd 100644
--- a/spec/requests/admin/badges_controller_spec.rb
+++ b/spec/requests/admin/badges_controller_spec.rb
@@ -177,5 +177,38 @@ describe Admin::BadgesController do
end
end
end
+
+ describe '#mass_award' do
+ it 'does nothing when there is no file' do
+ post "/admin/badges/award/#{badge.id}.json", params: { file: '' }
+
+ expect(response.status).to eq(400)
+ end
+
+ it 'does nothing when the badge id is not valid' do
+ post '/admin/badges/award/fake_id.json', params: { file: fixture_file_upload(Tempfile.new) }
+
+ expect(response.status).to eq(400)
+ end
+
+ it 'does nothing when the file is not a csv' do
+ file = file_from_fixtures('cropped.png')
+
+ post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) }
+
+ expect(response.status).to eq(400)
+ end
+
+ it 'creates the badge for an existing user' do
+ Jobs.run_immediately!
+
+ user = Fabricate(:user, email: 'user1@test.com')
+ file = file_from_fixtures('user_emails.csv', 'csv')
+
+ post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) }
+
+ expect(UserBadge.exists?(user: user, badge: badge)).to eq(true)
+ end
+ end
end
end