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";
|
||||
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",
|
||||
{ path: "/badges", resetNamespace: true },
|
||||
function() {
|
||||
this.route("award", { path: "/award/: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"}}
|
||||
<span>{{i18n 'admin.badges.new'}}</span>
|
||||
{{/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 class='content-list'>
|
||||
<ul class="admin-badge-list">
|
||||
{{#each model as |badge|}}
|
||||
<li class="admin-badge-list-item">
|
||||
{{#link-to 'adminBadges.show' badge.id}}
|
||||
{{#link-to selectedRoute badge.id}}
|
||||
{{badge-button badge=badge}}
|
||||
{{#if badge.newBadge}}
|
||||
<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-groupings-modal {
|
||||
.badge-groupings {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
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
|
||||
|
|
Loading…
Reference in New Issue