Skip to content

Commit

Permalink
FEATURE: Add option to grant badge multiple times to users using Bulk…
Browse files Browse the repository at this point in the history
… Award (#13571)

Currently when bulk-awarding a badge that can be granted multiple times, users in the CSV file are granted the badge once no matter how many times they're listed in the file and only if they don't have the badge already.

This PR adds a new option to the Badge Bulk Award feature so that it's possible to grant users a badge even if they already have the badge and as many times as they appear in the CSV file.
  • Loading branch information
OsamaSayegh committed Jul 15, 2021
1 parent 0109edb commit 31aa701
Show file tree
Hide file tree
Showing 14 changed files with 548 additions and 96 deletions.
116 changes: 88 additions & 28 deletions app/assets/javascripts/admin/addon/controllers/admin-badges-award.js
Expand Up @@ -2,38 +2,98 @@ import Controller from "@ember/controller";
import I18n from "I18n";
import { ajax } from "discourse/lib/ajax";
import bootbox from "bootbox";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { extractError } from "discourse/lib/ajax-error";
import { action } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";

export default Controller.extend({
saving: false,
replaceBadgeOwners: false,
grantExistingHolders: false,
fileSelected: false,
unmatchedEntries: null,
resultsMessage: null,
success: false,
unmatchedEntriesCount: 0,

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);
options.data.append("replace_badge_owners", this.replaceBadgeOwners);

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"));
}
},
resetState() {
this.setProperties({
saving: false,
unmatchedEntries: null,
resultsMessage: null,
success: false,
unmatchedEntriesCount: 0,
});
this.send("updateFileSelected");
},

@discourseComputed("fileSelected", "saving")
massAwardButtonDisabled(fileSelected, saving) {
return !fileSelected || saving;
},

@discourseComputed("unmatchedEntriesCount", "unmatchedEntries.length")
unmatchedEntriesTruncated(unmatchedEntriesCount, length) {
return unmatchedEntriesCount && length && unmatchedEntriesCount > length;
},

@action
updateFileSelected() {
this.set(
"fileSelected",
!!document.querySelector("#massAwardCSVUpload")?.files?.length
);
},

@action
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);
options.data.append("replace_badge_owners", this.replaceBadgeOwners);
options.data.append("grant_existing_holders", this.grantExistingHolders);

this.resetState();
this.set("saving", true);

ajax(`/admin/badges/award/${this.model.id}`, options)
.then(
({
matched_users_count: matchedCount,
unmatched_entries: unmatchedEntries,
unmatched_entries_count: unmatchedEntriesCount,
}) => {
this.setProperties({
resultsMessage: I18n.t("admin.badges.mass_award.success", {
count: matchedCount,
}),
success: true,
});
if (unmatchedEntries.length) {
this.setProperties({
unmatchedEntries,
unmatchedEntriesCount,
});
}
}
)
.catch((error) => {
this.setProperties({
resultsMessage: extractError(error),
success: false,
});
})
.finally(() => this.set("saving", false));
} else {
bootbox.alert(I18n.t("admin.badges.mass_award.aborted"));
}
},
});
Expand Up @@ -9,4 +9,9 @@ export default Route.extend({
);
}
},

setupController(controller) {
this._super(...arguments);
controller.resetState();
},
});
43 changes: 40 additions & 3 deletions app/assets/javascripts/admin/addon/templates/badges-award.hbs
Expand Up @@ -14,25 +14,62 @@
</div>
<div>
<h4>{{i18n "admin.badges.mass_award.upload_csv"}}</h4>
<input type="file" id="massAwardCSVUpload" accept=".csv">
<input type="file" id="massAwardCSVUpload" accept=".csv" onchange={{action "updateFileSelected"}}>
</div>
<div>
<label>
{{input type="checkbox" checked=replaceBadgeOwners}}
{{i18n "admin.badges.mass_award.replace_owners"}}
</label>
{{#if model.multiple_grant}}
<label class="grant-existing-holders">
{{input type="checkbox" checked=grantExistingHolders class="grant-existing-holders-checkbox"}}
{{i18n "admin.badges.mass_award.grant_existing_holders"}}
</label>
{{/if}}
</div>
{{d-button
class="btn-primary"
action=(action "massAward")
type="submit"
disabled=saving
disabled=massAwardButtonDisabled
icon="certificate"
label="admin.badges.mass_award.perform"}}
{{#link-to "adminBadges.index" class="btn btn-danger"}}
{{#link-to "adminBadges.index" class="btn btn-normal"}}
{{d-icon "times"}}
<span>{{i18n "cancel"}}</span>
{{/link-to}}
</form>
{{#if saving}}
{{i18n "uploading"}}
{{/if}}
{{#if resultsMessage}}
<p>
{{#if success}}
{{d-icon "check" class="bulk-award-status-icon success"}}
{{else}}
{{d-icon "times" class="bulk-award-status-icon failure"}}
{{/if}}
{{resultsMessage}}
</p>
{{#if unmatchedEntries.length}}
<p>
{{d-icon "exclamation-triangle" class="bulk-award-status-icon failure"}}
<span>
{{#if unmatchedEntriesTruncated}}
{{i18n "admin.badges.mass_award.csv_has_unmatched_users_truncated_list" count=unmatchedEntriesCount}}
{{else}}
{{i18n "admin.badges.mass_award.csv_has_unmatched_users"}}
{{/if}}
</span>
</p>
<ul>
{{#each unmatchedEntries as |entry|}}
<li>{{entry}}</li>
{{/each}}
</ul>
{{/if}}
{{/if}}
{{else}}
<span class="badge-required">{{i18n "admin.badges.mass_award.no_badge_selected"}}</span>
{{/if}}
Expand Down
@@ -0,0 +1,32 @@
import {
acceptance,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
import I18n from "I18n";

acceptance("Admin - Badges - Mass Award", function (needs) {
needs.user();
test("when the badge can be granted multiple times", async function (assert) {
await visit("/admin/badges/award/new");
await click(
'.admin-badge-list-item span[data-badge-name="Both image and icon"]'
);
assert.equal(
query("label.grant-existing-holders").textContent.trim(),
I18n.t("admin.badges.mass_award.grant_existing_holders"),
"checkbox for granting existing holders is displayed"
);
});

test("when the badge can not be granted multiple times", async function (assert) {
await visit("/admin/badges/award/new");
await click('.admin-badge-list-item span[data-badge-name="Only icon"]');
assert.ok(
!exists(".grant-existing-holders"),
"checkbox for granting existing holders is not displayed"
);
});
});
Expand Up @@ -1726,6 +1726,7 @@ export default {
name: "Both image and icon",
icon: "fa-rocket",
image_url: "/assets/some-image.png",
multiple_grant: true,
},
]
}
Expand Down
12 changes: 12 additions & 0 deletions app/assets/stylesheets/common/admin/badges.scss
Expand Up @@ -134,6 +134,18 @@
.award-badge {
margin: 15px 0 0 15px;
float: left;
max-width: 70%;

.bulk-award-status-icon {
margin-right: 3px;

&.success {
color: var(--success);
}
&.failure {
color: var(--danger);
}
}

.badge-preview {
min-height: 110px;
Expand Down
56 changes: 36 additions & 20 deletions app/controllers/admin/badges_controller.rb
Expand Up @@ -3,6 +3,8 @@
require 'csv'

class Admin::BadgesController < Admin::AdminController
MAX_CSV_LINES = 50_000
BATCH_SIZE = 200

def index
data = {
Expand Down Expand Up @@ -52,37 +54,50 @@ def mass_award
end

replace_badge_owners = params[:replace_badge_owners] == 'true'
BadgeGranter.revoke_all(badge) if replace_badge_owners
ensure_users_have_badge_once = params[:grant_existing_holders] != 'true'
if !ensure_users_have_badge_once && !badge.multiple_grant?
render_json_error(
I18n.t('badges.mass_award.errors.cant_grant_multiple_times', badge_name: badge.display_name),
status: 422
)
return
end

batch_number = 1
line_number = 1
batch = []

usernames = []
emails = []
File.open(csv_file) do |csv|
mode = Email.is_valid?(CSV.parse_line(csv.first).first) ? 'email' : 'username'
csv.rewind

csv.each_line do |email_line|
line = CSV.parse_line(email_line).first
csv.each_line do |line|
line = CSV.parse_line(line).first&.strip
line_number += 1

if line.present?
batch << line
line_number += 1
if line.include?('@')
emails << line
else
usernames << line
end
end

# 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, users_batch: batch, badge_id: badge.id, mode: mode)
batch = []
batch_number += 1
if emails.size + usernames.size > MAX_CSV_LINES
return render_json_error I18n.t('badges.mass_award.errors.too_many_csv_entries', count: MAX_CSV_LINES), status: 400
end
end
end
BadgeGranter.revoke_all(badge) if replace_badge_owners

head :ok
results = BadgeGranter.enqueue_mass_grant_for_users(
badge,
emails: emails,
usernames: usernames,
ensure_users_have_badge_once: ensure_users_have_badge_once
)

render json: {
unmatched_entries: results[:unmatched_entries].first(100),
matched_users_count: results[:matched_users_count],
unmatched_entries_count: results[:unmatched_entries_count]
}, status: :ok
rescue CSV::MalformedCSVError
render_json_error I18n.t('badges.mass_award.errors.invalid_csv', line_number: line_number), status: 400
end
Expand Down Expand Up @@ -147,6 +162,7 @@ def destroy
end

private

def find_badge
params.require(:id)
Badge.find(params[:id])
Expand Down
18 changes: 5 additions & 13 deletions app/jobs/regular/mass_award_badge.rb
Expand Up @@ -3,20 +3,12 @@
module Jobs
class MassAwardBadge < ::Jobs::Base
def execute(args)
return unless mode = args[:mode]
badge = Badge.find_by(id: args[:badge_id])
user = User.find_by(id: args[:user])
return if user.blank?
badge = Badge.find_by(enabled: true, id: args[:badge])
return if badge.blank?

users = User.select(:id, :username, :locale)

if mode == 'email'
users = users.with_email(args[:users_batch])
else
users = users.where(username_lower: args[:users_batch].map!(&:downcase))
end

return if users.empty? || badge.nil?

BadgeGranter.mass_grant(badge, users)
BadgeGranter.mass_grant(badge, user, count: args[:count])
end
end
end

0 comments on commit 31aa701

Please sign in to comment.