Feature: Mass award badge (#8694)
* UI: Mass grant a badge from the admin ui * Send the uploaded CSV and badge ID to the backend * Read the CSV and grant badge in batches * UX: Communicate the result to the user * Don't award if badge is disabled * Create a 'send_notification' method to remove duplicated code, slightly shrink badge image. Replace router transition with href. * Dynamically discover current route
This commit is contained in:
parent
eb105ba79d
commit
d69c5eebcf
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -1,2 +1,18 @@
|
||||||
import Controller from "@ember/controller";
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
|
@ -190,6 +190,7 @@ export default function() {
|
||||||
"adminBadges",
|
"adminBadges",
|
||||||
{ path: "/badges", resetNamespace: true },
|
{ path: "/badges", resetNamespace: true },
|
||||||
function() {
|
function() {
|
||||||
|
this.route("award", { path: "/award/:badge_id" });
|
||||||
this.route("show", { path: "/:badge_id" });
|
this.route("show", { path: "/:badge_id" });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
{{#d-section class="award-badge"}}
|
||||||
|
<form class="form-horizontal">
|
||||||
|
<h1>{{i18n 'admin.badges.mass_award.title'}}</h1>
|
||||||
|
<div class='badge-preview'>
|
||||||
|
{{#if model}}
|
||||||
|
{{icon-or-image model}}
|
||||||
|
<span class="badge-display-name">{{model.name}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class='badge-placeholder'>{{I18n 'admin.badges.mass_award.no_badge_selected'}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>{{I18n 'admin.badges.mass_award.upload_csv'}}</h4>
|
||||||
|
<input type='file' id='massAwardCSVUpload' accept='.csv' />
|
||||||
|
</div>
|
||||||
|
{{d-button
|
||||||
|
class="btn-primary"
|
||||||
|
action=(action 'massAward')
|
||||||
|
disabled=saving
|
||||||
|
label="admin.badges.save"}}
|
||||||
|
</form>
|
||||||
|
{{/d-section}}
|
|
@ -6,13 +6,18 @@
|
||||||
{{d-icon "plus"}}
|
{{d-icon "plus"}}
|
||||||
<span>{{i18n 'admin.badges.new'}}</span>
|
<span>{{i18n 'admin.badges.new'}}</span>
|
||||||
{{/link-to}}
|
{{/link-to}}
|
||||||
|
|
||||||
|
{{#link-to 'adminBadges.award' 'new' class="btn btn-primary"}}
|
||||||
|
{{d-icon "certificate"}}
|
||||||
|
<span>{{i18n 'admin.badges.mass_award.button'}}</span>
|
||||||
|
{{/link-to}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='content-list'>
|
<div class='content-list'>
|
||||||
<ul class="admin-badge-list">
|
<ul class="admin-badge-list">
|
||||||
{{#each model as |badge|}}
|
{{#each model as |badge|}}
|
||||||
<li class="admin-badge-list-item">
|
<li class="admin-badge-list-item">
|
||||||
{{#link-to 'adminBadges.show' badge.id}}
|
{{#link-to selectedRoute badge.id}}
|
||||||
{{badge-button badge=badge}}
|
{{badge-button badge=badge}}
|
||||||
{{#if badge.newBadge}}
|
{{#if badge.newBadge}}
|
||||||
<span class="list-badge">{{i18n 'filters.new.lower_title'}}</span>
|
<span class="list-badge">{{i18n 'filters.new.lower_title'}}</span>
|
||||||
|
|
|
@ -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-grouping modal
|
||||||
.badge-groupings-modal {
|
.badge-groupings-modal {
|
||||||
.badge-groupings {
|
.badge-groupings {
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'csv'
|
||||||
|
|
||||||
class Admin::BadgesController < Admin::AdminController
|
class Admin::BadgesController < Admin::AdminController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@ -33,6 +35,38 @@ class Admin::BadgesController < Admin::AdminController
|
||||||
def show
|
def show
|
||||||
end
|
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
|
def badge_types
|
||||||
badge_types = BadgeType.all.to_a
|
badge_types = BadgeType.all.to_a
|
||||||
render_serialized(badge_types, BadgeTypeSerializer, root: "badge_types")
|
render_serialized(badge_types, BadgeTypeSerializer, root: "badge_types")
|
||||||
|
|
|
@ -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
|
|
@ -12,6 +12,25 @@ class BadgeGranter
|
||||||
BadgeGranter.new(badge, user, opts).grant
|
BadgeGranter.new(badge, user, opts).grant
|
||||||
end
|
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
|
def grant
|
||||||
return if @granted_by && !Guardian.new(@granted_by).can_grant_badges?(@user)
|
return if @granted_by && !Guardian.new(@granted_by).can_grant_badges?(@user)
|
||||||
return unless @badge.enabled?
|
return unless @badge.enabled?
|
||||||
|
@ -46,17 +65,9 @@ class BadgeGranter
|
||||||
|
|
||||||
if SiteSetting.enable_badges?
|
if SiteSetting.enable_badges?
|
||||||
unless @badge.badge_type_id == BadgeType::Bronze && user_badge.granted_at < 2.days.ago
|
unless @badge.badge_type_id == BadgeType::Bronze && user_badge.granted_at < 2.days.ago
|
||||||
I18n.with_locale(@user.effective_locale) do
|
notification = self.class.send_notification(@user.id, @user.username, @user.effective_locale, @badge)
|
||||||
notification = @user.notifications.create(
|
|
||||||
notification_type: Notification.types[:granted_badge],
|
user_badge.update notification_id: notification.id
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -331,29 +342,9 @@ class BadgeGranter
|
||||||
|
|
||||||
# old bronze badges do not matter
|
# old bronze badges do not matter
|
||||||
next if badge.badge_type_id == BadgeType::Bronze && row.granted_at < 2.days.ago
|
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?
|
next if row.staff && badge.awarded_for_trust_level?
|
||||||
|
|
||||||
notification = I18n.with_locale(notification_locale) do
|
notification = send_notification(row.user_id, row.username, row.locale, badge)
|
||||||
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
|
|
||||||
|
|
||||||
DB.exec(
|
DB.exec(
|
||||||
"UPDATE user_badges SET notification_id = :notification_id WHERE id = :id",
|
"UPDATE user_badges SET notification_id = :notification_id WHERE id = :id",
|
||||||
|
@ -387,4 +378,23 @@ class BadgeGranter
|
||||||
SQL
|
SQL
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -4493,6 +4493,13 @@ en:
|
||||||
title: "Select an existing badge or create a new one to get started"
|
title: "Select an existing badge or create a new one to get started"
|
||||||
what_are_badges_title: "What are badges?"
|
what_are_badges_title: "What are badges?"
|
||||||
badge_query_examples_title: "Badge query examples"
|
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:
|
emoji:
|
||||||
title: "Emoji"
|
title: "Emoji"
|
||||||
|
|
|
@ -295,6 +295,8 @@ Discourse::Application.routes.draw do
|
||||||
|
|
||||||
resources :badges, constraints: AdminConstraint.new do
|
resources :badges, constraints: AdminConstraint.new do
|
||||||
collection do
|
collection do
|
||||||
|
get "/award/:badge_id" => "badges#award"
|
||||||
|
post "/award/:badge_id" => "badges#mass_award"
|
||||||
get "types" => "badges#badge_types"
|
get "types" => "badges#badge_types"
|
||||||
post "badge_groupings" => "badges#save_badge_groupings"
|
post "badge_groupings" => "badges#save_badge_groupings"
|
||||||
post "preview" => "badges#preview"
|
post "preview" => "badges#preview"
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
user1@test.com
|
||||||
|
user2@test.com
|
||||||
|
user3@test.com
|
||||||
|
user4@test.com
|
|
|
@ -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
|
|
@ -177,5 +177,38 @@ describe Admin::BadgesController do
|
||||||
end
|
end
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue