FEATURE: Add option to grant badge multiple times to users using Bulk 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.
This commit is contained in:
parent
0109edb847
commit
31aa701518
|
@ -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];
|
||||
resetState() {
|
||||
this.setProperties({
|
||||
saving: false,
|
||||
unmatchedEntries: null,
|
||||
resultsMessage: null,
|
||||
success: false,
|
||||
unmatchedEntriesCount: 0,
|
||||
});
|
||||
this.send("updateFileSelected");
|
||||
},
|
||||
|
||||
if (this.model && file) {
|
||||
const options = {
|
||||
type: "POST",
|
||||
processData: false,
|
||||
contentType: false,
|
||||
data: new FormData(),
|
||||
};
|
||||
@discourseComputed("fileSelected", "saving")
|
||||
massAwardButtonDisabled(fileSelected, saving) {
|
||||
return !fileSelected || saving;
|
||||
},
|
||||
|
||||
options.data.append("file", file);
|
||||
options.data.append("replace_badge_owners", this.replaceBadgeOwners);
|
||||
@discourseComputed("unmatchedEntriesCount", "unmatchedEntries.length")
|
||||
unmatchedEntriesTruncated(unmatchedEntriesCount, length) {
|
||||
return unmatchedEntriesCount && length && unmatchedEntriesCount > length;
|
||||
},
|
||||
|
||||
this.set("saving", true);
|
||||
@action
|
||||
updateFileSelected() {
|
||||
this.set(
|
||||
"fileSelected",
|
||||
!!document.querySelector("#massAwardCSVUpload")?.files?.length
|
||||
);
|
||||
},
|
||||
|
||||
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"));
|
||||
}
|
||||
},
|
||||
@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"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -9,4 +9,9 @@ export default Route.extend({
|
|||
);
|
||||
}
|
||||
},
|
||||
|
||||
setupController(controller) {
|
||||
this._super(...arguments);
|
||||
controller.resetState();
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1726,6 +1726,7 @@ export default {
|
|||
name: "Both image and icon",
|
||||
icon: "fa-rocket",
|
||||
image_url: "/assets/some-image.png",
|
||||
multiple_grant: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
require 'csv'
|
||||
|
||||
class Admin::BadgesController < Admin::AdminController
|
||||
MAX_CSV_LINES = 50_000
|
||||
BATCH_SIZE = 200
|
||||
|
||||
def index
|
||||
data = {
|
||||
|
@ -52,37 +54,50 @@ class Admin::BadgesController < Admin::AdminController
|
|||
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
|
||||
|
@ -147,6 +162,7 @@ class Admin::BadgesController < Admin::AdminController
|
|||
end
|
||||
|
||||
private
|
||||
|
||||
def find_badge
|
||||
params.require(:id)
|
||||
Badge.find(params[:id])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -21,23 +21,87 @@ class BadgeGranter
|
|||
BadgeGranter.new(badge, user, opts).grant
|
||||
end
|
||||
|
||||
def self.mass_grant(badge, users)
|
||||
return unless badge.enabled?
|
||||
def self.enqueue_mass_grant_for_users(badge, emails: [], usernames: [], ensure_users_have_badge_once: true)
|
||||
emails = emails.map(&:downcase)
|
||||
usernames = usernames.map(&:downcase)
|
||||
usernames_map_to_ids = {}
|
||||
emails_map_to_ids = {}
|
||||
if usernames.size > 0
|
||||
usernames_map_to_ids = User.where(username_lower: usernames).pluck(:username_lower, :id).to_h
|
||||
end
|
||||
if emails.size > 0
|
||||
emails_map_to_ids = User.with_email(emails).pluck('LOWER(user_emails.email)', :id).to_h
|
||||
end
|
||||
|
||||
system_user_id = Discourse.system_user.id
|
||||
now = Time.zone.now
|
||||
user_badges = users.map { |u| { badge_id: badge.id, user_id: u.id, granted_by_id: system_user_id, granted_at: now, created_at: now } }
|
||||
granted_badges = UserBadge.insert_all(user_badges, returning: %i[user_id])
|
||||
count_per_user = {}
|
||||
unmatched = Set.new
|
||||
(usernames + emails).each do |entry|
|
||||
id = usernames_map_to_ids[entry] || emails_map_to_ids[entry]
|
||||
if id.blank?
|
||||
unmatched << entry
|
||||
next
|
||||
end
|
||||
|
||||
users.each do |user|
|
||||
if ensure_users_have_badge_once
|
||||
count_per_user[id] = 1
|
||||
else
|
||||
count_per_user[id] ||= 0
|
||||
count_per_user[id] += 1
|
||||
end
|
||||
end
|
||||
|
||||
existing_owners_ids = []
|
||||
if ensure_users_have_badge_once
|
||||
existing_owners_ids = UserBadge.where(badge: badge).distinct.pluck(:user_id)
|
||||
end
|
||||
count_per_user.each do |user_id, count|
|
||||
next if ensure_users_have_badge_once && existing_owners_ids.include?(user_id)
|
||||
|
||||
Jobs.enqueue(
|
||||
:mass_award_badge,
|
||||
user: user_id,
|
||||
badge: badge.id,
|
||||
count: count
|
||||
)
|
||||
end
|
||||
|
||||
{
|
||||
unmatched_entries: unmatched.to_a,
|
||||
matched_users_count: count_per_user.size,
|
||||
unmatched_entries_count: unmatched.size
|
||||
}
|
||||
end
|
||||
|
||||
def self.mass_grant(badge, user, count:)
|
||||
return if !badge.enabled?
|
||||
|
||||
raise ArgumentError.new("count can't be less than 1") if count < 1
|
||||
|
||||
UserBadge.transaction do
|
||||
DB.exec(<<~SQL * count, now: Time.zone.now, system: Discourse.system_user.id, user_id: user.id, badge_id: badge.id)
|
||||
INSERT INTO user_badges
|
||||
(granted_at, created_at, granted_by_id, user_id, badge_id, seq)
|
||||
VALUES
|
||||
(
|
||||
:now,
|
||||
:now,
|
||||
:system,
|
||||
:user_id,
|
||||
:badge_id,
|
||||
COALESCE((
|
||||
SELECT MAX(seq) + 1
|
||||
FROM user_badges
|
||||
WHERE badge_id = :badge_id AND user_id = :user_id
|
||||
), 0)
|
||||
);
|
||||
SQL
|
||||
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
|
||||
)
|
||||
DB.exec(<<~SQL, notification_id: notification.id, user_id: user.id, badge_id: badge.id)
|
||||
UPDATE user_badges
|
||||
SET notification_id = :notification_id
|
||||
WHERE notification_id IS NULL AND user_id = :user_id AND badge_id = :badge_id
|
||||
SQL
|
||||
|
||||
UserBadge.update_featured_ranks!(user.id)
|
||||
end
|
||||
|
|
|
@ -5249,8 +5249,11 @@ en:
|
|||
perform: "Award Badge to Users"
|
||||
upload_csv: Upload a CSV with either user emails or usernames
|
||||
aborted: Please upload a CSV containing either user emails or usernames
|
||||
success: Your CSV was received and users will receive their badge shortly.
|
||||
success: Your CSV was received and %{count} users will receive their badge shortly.
|
||||
csv_has_unmatched_users: "The following entries are in the CSV file but they couldn't be matched to existing users, and therefore won't receive the badge:"
|
||||
csv_has_unmatched_users_truncated_list: "There were %{count} entries in the CSV file that couldn't be matched to existing users, and therefore won't receive the badge. Due to the large number of unmatched entries, only the first 100 are shown:"
|
||||
replace_owners: Remove the badge from previous owners
|
||||
grant_existing_holders: Grant additional badges to existing badge holders
|
||||
|
||||
emoji:
|
||||
title: "Emoji"
|
||||
|
|
|
@ -4449,7 +4449,9 @@ en:
|
|||
mass_award:
|
||||
errors:
|
||||
invalid_csv: We encountered an error on line %{line_number}. Please confirm the CSV has one email per line.
|
||||
too_many_csv_entries: Too many entries in the CSV file. Please provide a CSV file with no more than %{count} entries.
|
||||
badge_disabled: Please enable the %{badge_name} badge first.
|
||||
cant_grant_multiple_times: Can't grant the %{badge_name} badge multiple times to a single user.
|
||||
editor:
|
||||
name: Editor
|
||||
description: First post edit
|
||||
|
|
|
@ -9,22 +9,13 @@ describe Jobs::MassAwardBadge do
|
|||
let(:email_mode) { 'email' }
|
||||
|
||||
it 'creates the badge for an existing user' do
|
||||
execute_job([user.email])
|
||||
execute_job(user)
|
||||
|
||||
expect(UserBadge.where(user: user, badge: badge).exists?).to eq(true)
|
||||
end
|
||||
|
||||
it 'works with multiple users' do
|
||||
user_2 = Fabricate(:user)
|
||||
|
||||
execute_job([user.email, user_2.email])
|
||||
|
||||
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
|
||||
execute_job([user.email])
|
||||
execute_job(user)
|
||||
|
||||
expect(Notification.exists?(user: user)).to eq(true)
|
||||
expect(UserBadge.where.not(notification_id: nil).exists?(user: user, badge: badge)).to eq(true)
|
||||
|
@ -35,14 +26,27 @@ describe Jobs::MassAwardBadge do
|
|||
|
||||
UserBadge.create!(badge_id: Badge::Member, user: user, granted_by: Discourse.system_user, granted_at: Time.now)
|
||||
|
||||
execute_job([user.email, user_2.email])
|
||||
execute_job(user)
|
||||
execute_job(user_2)
|
||||
|
||||
expect(UserBadge.find_by(user: user, badge: badge).featured_rank).to eq(2)
|
||||
expect(UserBadge.find_by(user: user_2, badge: badge).featured_rank).to eq(1)
|
||||
end
|
||||
|
||||
def execute_job(emails)
|
||||
subject.execute(users_batch: emails, badge_id: badge.id, mode: 'email')
|
||||
it 'grants a badge multiple times to a user' do
|
||||
badge.update!(multiple_grant: true)
|
||||
Notification.destroy_all
|
||||
execute_job(user, count: 4, grant_existing_holders: true)
|
||||
instances = UserBadge.where(user: user, badge: badge)
|
||||
expect(instances.count).to eq(4)
|
||||
expect(instances.pluck(:seq).sort).to eq((0...4).to_a)
|
||||
notifications = Notification.where(user: user)
|
||||
expect(notifications.count).to eq(1)
|
||||
expect(instances.map(&:notification_id).uniq).to contain_exactly(notifications.first.id)
|
||||
end
|
||||
|
||||
def execute_job(user, count: 1, grant_existing_holders: false)
|
||||
subject.execute(user: user.id, badge: badge.id, count: count, grant_existing_holders: grant_existing_holders)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -183,7 +183,7 @@ describe Admin::BadgesController do
|
|||
end
|
||||
|
||||
describe '#mass_award' do
|
||||
before { @user = Fabricate(:user, email: 'user1@test.com', username: 'username1') }
|
||||
fab!(:user) { Fabricate(:user, email: 'user1@test.com', username: 'username1') }
|
||||
|
||||
it 'does nothing when there is no file' do
|
||||
post "/admin/badges/award/#{badge.id}.json", params: { file: '' }
|
||||
|
@ -210,9 +210,12 @@ describe Admin::BadgesController do
|
|||
|
||||
file = file_from_fixtures('user_emails.csv', 'csv')
|
||||
|
||||
UserBadge.destroy_all
|
||||
post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) }
|
||||
|
||||
expect(UserBadge.exists?(user: @user, badge: badge)).to eq(true)
|
||||
expect(response.status).to eq(200)
|
||||
expect(UserBadge.where(user: user, badge: badge).count).to eq(1)
|
||||
expect(UserBadge.where(user: user, badge: badge).first.seq).to eq(0)
|
||||
end
|
||||
|
||||
it 'awards the badge using a list of usernames' do
|
||||
|
@ -222,7 +225,8 @@ describe Admin::BadgesController do
|
|||
|
||||
post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) }
|
||||
|
||||
expect(UserBadge.exists?(user: @user, badge: badge)).to eq(true)
|
||||
expect(response.status).to eq(200)
|
||||
expect(UserBadge.where(user: user, badge: badge).count).to eq(1)
|
||||
end
|
||||
|
||||
it 'works with a CSV containing nil values' do
|
||||
|
@ -232,7 +236,22 @@ describe Admin::BadgesController do
|
|||
|
||||
post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) }
|
||||
|
||||
expect(UserBadge.exists?(user: @user, badge: badge)).to eq(true)
|
||||
expect(response.status).to eq(200)
|
||||
expect(UserBadge.where(user: user, badge: badge).count).to eq(1)
|
||||
end
|
||||
|
||||
it 'does not grant the badge again to a user if they already have the badge' do
|
||||
Jobs.run_immediately!
|
||||
badge.update!(multiple_grant: true)
|
||||
BadgeGranter.grant(badge, user)
|
||||
user.reload
|
||||
|
||||
file = file_from_fixtures('usernames_with_nil_values.csv', 'csv')
|
||||
|
||||
post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) }
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(UserBadge.where(user: user, badge: badge).count).to eq(1)
|
||||
end
|
||||
|
||||
it 'fails when the badge is disabled' do
|
||||
|
@ -244,6 +263,103 @@ describe Admin::BadgesController do
|
|||
|
||||
expect(response.status).to eq(422)
|
||||
end
|
||||
|
||||
context "when grant_existing_holders is true" do
|
||||
it "fails when the badge cannot be granted multiple times" do
|
||||
file = file_from_fixtures('user_emails.csv', 'csv')
|
||||
badge.update!(multiple_grant: false)
|
||||
post "/admin/badges/award/#{badge.id}.json", params: {
|
||||
file: fixture_file_upload(file),
|
||||
grant_existing_holders: true
|
||||
}
|
||||
|
||||
expect(response.status).to eq(422)
|
||||
expect(response.parsed_body['errors']).to eq([
|
||||
I18n.t("badges.mass_award.errors.cant_grant_multiple_times", badge_name: badge.name)
|
||||
])
|
||||
end
|
||||
|
||||
it "fails when CSV file contains more entries that it's allowed" do
|
||||
badge.update!(multiple_grant: true)
|
||||
csv = Tempfile.new
|
||||
csv.write("#{user.username}\n" * 11)
|
||||
csv.rewind
|
||||
stub_const(Admin::BadgesController, "MAX_CSV_LINES", 10) do
|
||||
post "/admin/badges/award/#{badge.id}.json", params: {
|
||||
file: fixture_file_upload(csv),
|
||||
grant_existing_holders: true
|
||||
}
|
||||
end
|
||||
expect(response.status).to eq(400)
|
||||
expect(response.parsed_body["errors"]).to include(I18n.t("badges.mass_award.errors.too_many_csv_entries", count: 10))
|
||||
ensure
|
||||
csv&.close
|
||||
csv&.unlink
|
||||
end
|
||||
|
||||
it "includes unmatched entries and the number of users who will receive the badge in the response" do
|
||||
Jobs.run_immediately!
|
||||
badge.update!(multiple_grant: true)
|
||||
csv = Tempfile.new
|
||||
content = [
|
||||
"nonexistentuser",
|
||||
"nonexistentuser",
|
||||
"nonexistentemail@discourse.fake"
|
||||
]
|
||||
content << user.username
|
||||
content << user.username
|
||||
csv.write(content.join("\n"))
|
||||
csv.rewind
|
||||
post "/admin/badges/award/#{badge.id}.json", params: {
|
||||
file: fixture_file_upload(csv),
|
||||
grant_existing_holders: true
|
||||
}
|
||||
expect(response.status).to eq(200)
|
||||
expect(response.parsed_body['unmatched_entries']).to contain_exactly(
|
||||
"nonexistentuser",
|
||||
"nonexistentemail@discourse.fake"
|
||||
)
|
||||
expect(response.parsed_body['matched_users_count']).to eq(1)
|
||||
expect(response.parsed_body['unmatched_entries_count']).to eq(2)
|
||||
expect(UserBadge.where(user: user, badge: badge).count).to eq(2)
|
||||
ensure
|
||||
csv&.close
|
||||
csv&.unlink
|
||||
end
|
||||
|
||||
it "grants the badge to the users in the CSV as many times as they appear in it" do
|
||||
Jobs.run_immediately!
|
||||
badge.update!(multiple_grant: true)
|
||||
user_without_badge = Fabricate(:user)
|
||||
user_with_badge = Fabricate(:user).tap { |u| BadgeGranter.grant(badge, u) }
|
||||
|
||||
csv_content = [
|
||||
user_with_badge.email.titlecase,
|
||||
user_with_badge.username.titlecase,
|
||||
user_without_badge.email.titlecase,
|
||||
user_without_badge.username.titlecase
|
||||
] * 20
|
||||
|
||||
csv = Tempfile.new
|
||||
csv.write(csv_content.join("\n"))
|
||||
csv.rewind
|
||||
post "/admin/badges/award/#{badge.id}.json", params: {
|
||||
file: fixture_file_upload(csv),
|
||||
grant_existing_holders: true
|
||||
}
|
||||
expect(response.status).to eq(200)
|
||||
expect(response.parsed_body['unmatched_entries']).to eq([])
|
||||
expect(response.parsed_body['matched_users_count']).to eq(2)
|
||||
expect(response.parsed_body['unmatched_entries_count']).to eq(0)
|
||||
sequence = UserBadge.where(user: user_with_badge, badge: badge).pluck(:seq)
|
||||
expect(sequence.sort).to eq((0...(40 + 1)).to_a)
|
||||
sequence = UserBadge.where(user: user_without_badge, badge: badge).pluck(:seq)
|
||||
expect(sequence.sort).to eq((0...40).to_a)
|
||||
ensure
|
||||
csv&.close
|
||||
csv&.unlink
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -486,4 +486,112 @@ describe BadgeGranter do
|
|||
expect(BadgeGranter.notification_locale('pl_PL')).to eq('pl_PL')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.mass_grant' do
|
||||
it 'raises an error if the count argument is less than 1' do
|
||||
expect do
|
||||
BadgeGranter.mass_grant(badge, user, count: 0)
|
||||
end.to raise_error(ArgumentError, "count can't be less than 1")
|
||||
end
|
||||
|
||||
it 'grants the badge to the user as many times as the count argument' do
|
||||
BadgeGranter.mass_grant(badge, user, count: 10)
|
||||
sequence = UserBadge.where(badge: badge, user: user).pluck(:seq).sort
|
||||
expect(sequence).to eq((0...10).to_a)
|
||||
|
||||
BadgeGranter.mass_grant(badge, user, count: 10)
|
||||
sequence = UserBadge.where(badge: badge, user: user).pluck(:seq).sort
|
||||
expect(sequence).to eq((0...20).to_a)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.enqueue_mass_grant_for_users' do
|
||||
before { Jobs.run_immediately! }
|
||||
|
||||
it 'returns a list of the entries that could not be matched to any users' do
|
||||
results = BadgeGranter.enqueue_mass_grant_for_users(
|
||||
badge,
|
||||
emails: ['fakeemail@discourse.invalid', user.email],
|
||||
usernames: [user.username, 'fakeusername'],
|
||||
)
|
||||
expect(results[:unmatched_entries]).to contain_exactly(
|
||||
'fakeemail@discourse.invalid',
|
||||
'fakeusername'
|
||||
)
|
||||
expect(results[:matched_users_count]).to eq(1)
|
||||
expect(results[:unmatched_entries_count]).to eq(2)
|
||||
end
|
||||
|
||||
context 'when ensure_users_have_badge_once is true' do
|
||||
it 'ensures each user has the badge at least once and does not grant the badge multiple times to one user' do
|
||||
BadgeGranter.grant(badge, user)
|
||||
user_without_badge = Fabricate(:user)
|
||||
|
||||
Notification.destroy_all
|
||||
results = BadgeGranter.enqueue_mass_grant_for_users(
|
||||
badge,
|
||||
usernames: [
|
||||
user.username,
|
||||
user.username,
|
||||
user_without_badge.username,
|
||||
user_without_badge.username
|
||||
],
|
||||
ensure_users_have_badge_once: true
|
||||
)
|
||||
expect(results[:unmatched_entries]).to eq([])
|
||||
expect(results[:matched_users_count]).to eq(2)
|
||||
expect(results[:unmatched_entries_count]).to eq(0)
|
||||
|
||||
sequence = UserBadge.where(user: user, badge: badge).pluck(:seq)
|
||||
expect(sequence).to contain_exactly(0)
|
||||
# no new badge/notification because user already had the badge
|
||||
# before enqueue_mass_grant_for_users was called
|
||||
expect(user.reload.notifications.size).to eq(0)
|
||||
|
||||
sequence = UserBadge.where(user: user_without_badge, badge: badge)
|
||||
expect(sequence.pluck(:seq)).to contain_exactly(0)
|
||||
notifications = user_without_badge.reload.notifications
|
||||
expect(notifications.size).to eq(1)
|
||||
expect(sequence.first.notification_id).to eq(notifications.first.id)
|
||||
expect(notifications.first.notification_type).to eq(Notification.types[:granted_badge])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ensure_users_have_badge_once is false' do
|
||||
it 'grants the badge to the users as many times as they appear in the emails and usernames arguments' do
|
||||
badge.update!(multiple_grant: true)
|
||||
user_without_badge = Fabricate(:user)
|
||||
user_with_badge = Fabricate(:user).tap { |u| BadgeGranter.grant(badge, u) }
|
||||
|
||||
Notification.destroy_all
|
||||
emails = [user_with_badge.email.titlecase, user_without_badge.email.titlecase] * 20
|
||||
usernames = [user_with_badge.username.titlecase, user_without_badge.username.titlecase] * 20
|
||||
|
||||
results = BadgeGranter.enqueue_mass_grant_for_users(
|
||||
badge,
|
||||
emails: emails,
|
||||
usernames: usernames,
|
||||
ensure_users_have_badge_once: false
|
||||
)
|
||||
expect(results[:unmatched_entries]).to eq([])
|
||||
expect(results[:matched_users_count]).to eq(2)
|
||||
expect(results[:unmatched_entries_count]).to eq(0)
|
||||
|
||||
sequence = UserBadge.where(user: user_with_badge, badge: badge).pluck(:seq)
|
||||
expect(sequence.size).to eq(40 + 1)
|
||||
expect(sequence.sort).to eq((0...(40 + 1)).to_a)
|
||||
sequence = UserBadge.where(user: user_without_badge, badge: badge).pluck(:seq)
|
||||
expect(sequence.size).to eq(40)
|
||||
expect(sequence.sort).to eq((0...40).to_a)
|
||||
|
||||
# each user gets 1 notification no matter how many times
|
||||
# they're repeated in the file.
|
||||
[user_without_badge, user_with_badge].each do |u|
|
||||
notifications = u.reload.notifications
|
||||
expect(notifications.size).to eq(1)
|
||||
expect(notifications.map(&:notification_type).uniq).to contain_exactly(Notification.types[:granted_badge])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue