Feature: Mass award badge (#8694)

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

diff --git a/app/assets/javascripts/admin/controllers/admin-badges-award.js.es6 b/app/assets/javascripts/admin/controllers/admin-badges-award.js.es6
new file mode 100644
index 0000000000..af3afd528b
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-badges-award.js.es6
@@ -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"));
+      }
+    }
+  }
+});
diff --git a/app/assets/javascripts/admin/controllers/admin-badges.js.es6 b/app/assets/javascripts/admin/controllers/admin-badges.js.es6
index cf6c4e3aa2..4797fb2695 100644
--- a/app/assets/javascripts/admin/controllers/admin-badges.js.es6
+++ b/app/assets/javascripts/admin/controllers/admin-badges.js.es6
@@ -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;
+    }
+  }
+});
diff --git a/app/assets/javascripts/admin/routes/admin-badges-award.js.es6 b/app/assets/javascripts/admin/routes/admin-badges-award.js.es6
new file mode 100644
index 0000000000..90a4cba17f
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-badges-award.js.es6
@@ -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)
+      );
+    }
+  }
+});
diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
index f90b66f97a..8297380e05 100644
--- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
@@ -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" });
       }
     );
diff --git a/app/assets/javascripts/admin/templates/badges-award.hbs b/app/assets/javascripts/admin/templates/badges-award.hbs
new file mode 100644
index 0000000000..cf60ff513f
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/badges-award.hbs
@@ -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}}
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/templates/badges.hbs b/app/assets/javascripts/admin/templates/badges.hbs
index 398a5c08fc..e1ade47b01 100644
--- a/app/assets/javascripts/admin/templates/badges.hbs
+++ b/app/assets/javascripts/admin/templates/badges.hbs
@@ -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>
diff --git a/app/assets/stylesheets/common/admin/badges.scss b/app/assets/stylesheets/common/admin/badges.scss
index 57df3636bd..c1f1bd43d1 100644
--- a/app/assets/stylesheets/common/admin/badges.scss
+++ b/app/assets/stylesheets/common/admin/badges.scss
@@ -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 {
diff --git a/app/controllers/admin/badges_controller.rb b/app/controllers/admin/badges_controller.rb
index fe67475e26..0f310d9a9b 100644
--- a/app/controllers/admin/badges_controller.rb
+++ b/app/controllers/admin/badges_controller.rb
@@ -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")
diff --git a/app/jobs/regular/mass_award_badge.rb b/app/jobs/regular/mass_award_badge.rb
new file mode 100644
index 0000000000..ae6db5f6f5
--- /dev/null
+++ b/app/jobs/regular/mass_award_badge.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Jobs
+  class MassAwardBadge < ::Jobs::Base
+    def execute(args)

[... diff too long, it was truncated ...]

GitHub sha: d69c5eeb

1 Like