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:
Roman Rizzi 2020-01-13 11:20:26 -03:00 committed by GitHub
parent eb105ba79d
commit d69c5eebcf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 291 additions and 34 deletions

View File

@ -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"));
}
}
}
});

View File

@ -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;
}
}
});

View File

@ -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)
);
}
}
});

View File

@ -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" });
} }
); );

View File

@ -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}}

View File

@ -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>

View File

@ -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 {

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

4
spec/fixtures/csv/user_emails.csv vendored Normal file
View File

@ -0,0 +1,4 @@
user1@test.com
user2@test.com
user3@test.com
user4@test.com
1 user1 test.com
2 user2 test.com
3 user3 test.com
4 user4 test.com

View File

@ -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

View File

@ -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