diff --git a/app/assets/javascripts/admin/controllers/admin_user_badges_controller.js b/app/assets/javascripts/admin/controllers/admin_user_badges_controller.js new file mode 100644 index 00000000000..a534195a129 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_user_badges_controller.js @@ -0,0 +1,52 @@ +/** + This controller supports the interface for granting and revoking badges from + individual users. + + @class AdminUserBadgesController + @extends Ember.ArrayController + @namespace Discourse + @module Discourse +**/ +Discourse.AdminUserBadgesController = Ember.ArrayController.extend({ + needs: ["adminUser"], + user: Em.computed.alias('controllers.adminUser'), + sortProperties: ['granted_at'], + sortAscending: false, + + actions: { + + /** + Grant the selected badge to the user. + + @method grantBadge + @param {Integer} badgeId id of the badge we want to grant. + **/ + grantBadge: function(badgeId) { + var self = this; + Discourse.UserBadge.grant(badgeId, this.get('user.username')).then(function(userBadge) { + self.pushObject(userBadge); + }, function() { + // Failure + bootbox.alert(I18n.t('generic_error')); + }); + }, + + /** + Revoke the selected userBadge. + + @method revokeBadge + @param {Discourse.UserBadge} userBadge the `Discourse.UserBadge` instance that needs to be revoked. + **/ + revokeBadge: function(userBadge) { + var self = this; + return bootbox.confirm(I18n.t("admin.badges.revoke_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) { + if (result) { + userBadge.revoke().then(function() { + self.get('model').removeObject(userBadge); + }); + } + }); + } + + } +}); diff --git a/app/assets/javascripts/admin/routes/admin_routes.js b/app/assets/javascripts/admin/routes/admin_routes.js index 21b2b38b6a6..1143df781ce 100644 --- a/app/assets/javascripts/admin/routes/admin_routes.js +++ b/app/assets/javascripts/admin/routes/admin_routes.js @@ -47,6 +47,7 @@ Discourse.Route.buildRoutes(function() { this.resource('adminUsers', { path: '/users' }, function() { this.resource('adminUser', { path: '/:username' }, function() { + this.route('badges'); this.route('leaderRequirements', { path: '/leader_requirements' }); }); this.resource('adminUsersList', { path: '/list' }, function() { diff --git a/app/assets/javascripts/admin/routes/admin_user_badges_route.js b/app/assets/javascripts/admin/routes/admin_user_badges_route.js new file mode 100644 index 00000000000..b1184b3b12e --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_user_badges_route.js @@ -0,0 +1,31 @@ +/** + Shows all of the badges that have been granted to a user, and allow granting and + revoking badges. + + @class AdminUserBadgesRoute + @extends Discourse.Route + @namespace Discourse + @module Discourse +**/ +Discourse.AdminUserBadgesRoute = Discourse.Route.extend({ + model: function() { + var username = this.controllerFor('adminUser').get('username'); + return Discourse.UserBadge.findByUsername(username); + }, + + setupController: function(controller, model) { + // Find all badges. + controller.set('loading', true); + Discourse.Badge.findAll().then(function(badges) { + controller.set('badges', badges); + if (badges.length > 0) { + controller.set('selectedBadgeId', badges[0].get('id')); + } else { + controller.set('noBadges', true); + } + controller.set('loading', false); + }); + // Set the model. + controller.set('model', model); + } +}); diff --git a/app/assets/javascripts/admin/templates/user_badges.js.handlebars b/app/assets/javascripts/admin/templates/user_badges.js.handlebars new file mode 100644 index 00000000000..aec5a82889f --- /dev/null +++ b/app/assets/javascripts/admin/templates/user_badges.js.handlebars @@ -0,0 +1,61 @@ +<div class='admin-controls'> + <div class='span15'> + <ul class='nav nav-pills'> + <li>{{#link-to 'adminUser' user}}<i class="fa fa-caret-left"></i> {{user.username}}{{/link-to}}</li> + </ul> + </div> +</div> + +{{#if loading}} + <div class='spinner'>{{i18n loading}}</div> +{{else}} + <div class='admin-container user-badges'> + <h2>{{i18n admin.badges.grant_badge}}</h2> + {{#if noBadges}} + <p>{{i18n admin.badges.no_badges}}</p> + {{else}} + <br> + {{combobox valueAttribute="id" value=controller.selectedBadgeId content=controller.badges}} + <button class='btn btn-primary' {{action grantBadge controller.selectedBadgeId}}>{{i18n admin.badges.grant}}</button> + {{/if}} + + <br> + <br> + + <h2>{{i18n admin.badges.granted_badges}}</h2> + <br> + + <table> + <tr> + <th>{{i18n admin.badges.name}}</th> + <th>{{i18n admin.badges.badge_type}}</th> + <th>{{i18n admin.badges.granted_by}}</th> + <th>{{i18n admin.badges.granted_at}}</th> + <th></th> + </tr> + + {{#each}} + <tr> + <td>{{badge.displayName}}</td> + <td>{{badge.badge_type.name}}</td> + <td> + {{#link-to 'adminUser' badge.granted_by}} + {{avatar granted_by imageSize="tiny"}} + {{granted_by.username}} + {{/link-to}} + </td> + <td>{{unboundAgeWithTooltip granted_at}}</td> + <td> + <button class='btn' {{action revokeBadge this}}>{{i18n admin.badges.revoke}}</button> + </td> + </tr> + {{else}} + <tr> + <td colspan="5"> + <p>{{i18n admin.badges.no_user_badges name=user.username}}</p> + </td> + </tr> + {{/each}} + </table> + </div> +{{/if}} diff --git a/app/assets/javascripts/admin/templates/user_index.js.handlebars b/app/assets/javascripts/admin/templates/user_index.js.handlebars index 8af5cd2c5f3..53c731de8ef 100644 --- a/app/assets/javascripts/admin/templates/user_index.js.handlebars +++ b/app/assets/javascripts/admin/templates/user_index.js.handlebars @@ -77,6 +77,18 @@ </div> </div> + {{#if showBadges}} + <div class='display-row'> + <div class='field'>{{i18n admin.badges.title}}</div> + <div class='value'> + TODO featured badges + </div> + <div class='controls'> + {{#link-to 'adminUser.badges' this class="btn"}}{{i18n admin.badges.edit_badges}}{{/link-to}} + </div> + </div> + {{/if}} + </section> @@ -336,12 +348,6 @@ </div> </section> -{{#if showBadges}} -<section class='details'> - <h1>{{i18n admin.badges.title}}</h1> -</section> -{{/if}} - <section> <hr/> <button {{bind-attr class=":btn :btn-danger :pull-right deleteForbidden:hidden"}} {{action destroy target="content"}} {{bind-attr disabled="deleteForbidden"}}> diff --git a/app/assets/javascripts/discourse/models/user_badge.js b/app/assets/javascripts/discourse/models/user_badge.js index f0f6595c831..3d521c1fa12 100644 --- a/app/assets/javascripts/discourse/models/user_badge.js +++ b/app/assets/javascripts/discourse/models/user_badge.js @@ -7,6 +7,17 @@ @module Discourse **/ Discourse.UserBadge = Discourse.Model.extend({ + /** + Revoke this badge. + + @method revoke + @returns {Promise} a promise that resolves when the badge has been revoked. + **/ + revoke: function() { + return Discourse.ajax("/user_badges/" + this.get('id'), { + type: "DELETE" + }); + } }); Discourse.UserBadge.reopenClass({ @@ -19,14 +30,15 @@ Discourse.UserBadge.reopenClass({ **/ createFromJson: function(json) { // Create User objects. + if (json.users === undefined) { json.users = []; } var users = {}; json.users.forEach(function(userJson) { users[userJson.id] = Discourse.User.create(userJson); }); // Create the badges. + if (json.badges === undefined) { json.badges = []; } var badges = {}; - Discourse.Badge.createFromJson(json).forEach(function(badge) { badges[badge.get('id')] = badge; }); @@ -53,5 +65,37 @@ Discourse.UserBadge.reopenClass({ } else { return userBadges; } + }, + + /** + Find all badges for a given username. + + @method findByUsername + @returns {Promise} a promise that resolves to an array of `Discourse.UserBadge`. + **/ + findByUsername: function(username) { + return Discourse.ajax("/user_badges.json?username=" + username).then(function(json) { + return Discourse.UserBadge.createFromJson(json); + }); + }, + + /** + Grant the badge having id `badgeId` to the user identified by `username`. + + @method grant + @param {Integer} badgeId id of the badge to be granted. + @param {String} username username of the user to be granted the badge. + @returns {Promise} a promise that resolves to an instance of `Discourse.UserBadge`. + **/ + grant: function(badgeId, username) { + return Discourse.ajax("/user_badges", { + type: "POST", + data: { + username: username, + badge_id: badgeId + } + }).then(function(json) { + return Discourse.UserBadge.createFromJson(json); + }); } }); diff --git a/app/assets/javascripts/discourse/views/combobox_view.js b/app/assets/javascripts/discourse/views/combobox_view.js index 8469c0f707d..25472a4b3ff 100644 --- a/app/assets/javascripts/discourse/views/combobox_view.js +++ b/app/assets/javascripts/discourse/views/combobox_view.js @@ -42,7 +42,7 @@ Discourse.ComboboxView = Discourse.View.extend({ if (val) { val = val.toString(); } var selectedText = (val === selected) ? "selected" : ""; - buffer.push("<option " + selectedText + " value=\"" + val + "\" " + self.buildData(o) + ">" + Em.get(o, nameProperty) + "</option>"); + buffer.push("<option " + selectedText + " value=\"" + val + "\" " + self.buildData(o) + ">" + Handlebars.Utils.escapeExpression(Em.get(o, nameProperty)) + "</option>"); }); } }, diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index b7abad65b06..000fb7c2a63 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -169,6 +169,9 @@ class Admin::UsersController < Admin::AdminController end end + def badges + end + def leader_requirements end diff --git a/app/controllers/user_badges_controller.rb b/app/controllers/user_badges_controller.rb index 3044d7c1656..ef5bbf3345f 100644 --- a/app/controllers/user_badges_controller.rb +++ b/app/controllers/user_badges_controller.rb @@ -2,7 +2,7 @@ class UserBadgesController < ApplicationController def index params.require(:username) user = fetch_user_from_params - render json: user.user_badges + render_serialized(user.user_badges, UserBadgeSerializer, root: "user_badges") end def create @@ -17,7 +17,7 @@ class UserBadgesController < ApplicationController badge = fetch_badge_from_params user_badge = BadgeGranter.grant(badge, user, granted_by: current_user) - render json: user_badge + render_serialized(user_badge, UserBadgeSerializer, root: "user_badge") end def destroy @@ -29,7 +29,7 @@ class UserBadgesController < ApplicationController return end - BadgeGranter.revoke(user_badge) + BadgeGranter.revoke(user_badge, revoked_by: current_user) render json: success_json end diff --git a/app/models/user_history.rb b/app/models/user_history.rb index fccf90cc298..bc4033c2c6d 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -23,7 +23,9 @@ class UserHistory < ActiveRecord::Base :notified_about_dominating_topic, :suspend_user, :unsuspend_user, - :facebook_no_email) + :facebook_no_email, + :grant_badge, + :revoke_badge) end # Staff actions is a subset of all actions, used to audit actions taken by staff users. @@ -34,7 +36,9 @@ class UserHistory < ActiveRecord::Base :change_site_customization, :delete_site_customization, :suspend_user, - :unsuspend_user] + :unsuspend_user, + :grant_badge, + :revoke_badge] end def self.staff_action_ids diff --git a/app/services/badge_granter.rb b/app/services/badge_granter.rb index 2fb74972dbe..d164f7d3ca0 100644 --- a/app/services/badge_granter.rb +++ b/app/services/badge_granter.rb @@ -19,15 +19,21 @@ class BadgeGranter granted_by: @granted_by, granted_at: Time.now) Badge.increment_counter 'grant_count', @badge.id + if @granted_by != Discourse.system_user + StaffActionLogger.new(@granted_by).log_badge_grant(user_badge) + end end user_badge end - def self.revoke(user_badge) + def self.revoke(user_badge, options={}) UserBadge.transaction do user_badge.destroy! Badge.decrement_counter 'grant_count', user_badge.badge.id + if options[:revoked_by] + StaffActionLogger.new(options[:revoked_by]).log_badge_revoke(user_badge) + end end end diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index bb9fa1f7401..b92deba695e 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -74,6 +74,24 @@ class StaffActionLogger })) end + def log_badge_grant(user_badge, opts={}) + raise Discourse::InvalidParameters.new('user_badge is nil') unless user_badge + UserHistory.create( params(opts).merge({ + action: UserHistory.actions[:grant_badge], + target_user_id: user_badge.user_id, + details: user_badge.badge.name + })) + end + + def log_badge_revoke(user_badge, opts={}) + raise Discourse::InvalidParameters.new('user_badge is nil') unless user_badge + UserHistory.create( params(opts).merge({ + action: UserHistory.actions[:revoke_badge], + target_user_id: user_badge.user_id, + details: user_badge.badge.name + })) + end + private def params(opts) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index c13503d0c84..c43de385f67 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1484,6 +1484,8 @@ en: delete_site_customization: "delete site customization" suspend_user: "suspend user" unsuspend_user: "unsuspend user" + grant_badge: "grant badge" + revoke_badge: "revoke badge" screened_emails: title: "Screened Emails" description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed." @@ -1678,9 +1680,19 @@ en: display_name: Display Name description: Description badge_type: Badge Type + granted_by: Granted By + granted_at: Granted At save: Save delete: Delete delete_confirm: Are you sure you want to delete this badge? + revoke: Revoke + revoke_confirm: Are you sure you want to revoke this badge? + edit_badges: Edit Badges + grant_badge: Grant Badge + granted_badges: Granted Badges + grant: Grant + no_user_badges: "%{name} has not been granted any badges." + no_badges: There are no badges that can be granted. lightbox: download: "download" diff --git a/config/routes.rb b/config/routes.rb index 40b9c6181d5..6e28b6b5cf8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,7 @@ Discourse::Application.routes.draw do put "unblock" put "trust_level" put "primary_group" + get "badges" get "leader_requirements" end diff --git a/spec/controllers/user_badges_controller_spec.rb b/spec/controllers/user_badges_controller_spec.rb index 4836df3ffd8..9d3fdbf1f43 100644 --- a/spec/controllers/user_badges_controller_spec.rb +++ b/spec/controllers/user_badges_controller_spec.rb @@ -36,6 +36,7 @@ describe UserBadgesController do it 'grants badges from staff' do admin = Fabricate(:admin) log_in_user admin + StaffActionLogger.any_instance.expects(:log_badge_grant).once xhr :post, :create, badge_id: badge.id, username: user.username response.status.should == 200 user_badge = UserBadge.where(user: user, badge: badge).first @@ -51,6 +52,7 @@ describe UserBadgesController do it 'grants badges from master api calls' do api_key = Fabricate(:api_key) + StaffActionLogger.any_instance.expects(:log_badge_grant).never xhr :post, :create, badge_id: badge.id, username: user.username, api_key: api_key.key response.status.should == 200 user_badge = UserBadge.where(user: user, badge: badge).first @@ -71,6 +73,7 @@ describe UserBadgesController do it 'revokes the badge' do log_in :admin + StaffActionLogger.any_instance.expects(:log_badge_revoke).once xhr :delete, :destroy, id: @user_badge.id response.status.should == 200 UserBadge.where(id: @user_badge.id).first.should be_nil diff --git a/spec/services/badge_granter_spec.rb b/spec/services/badge_granter_spec.rb index 1ffcde77ffc..6801bc969d1 100644 --- a/spec/services/badge_granter_spec.rb +++ b/spec/services/badge_granter_spec.rb @@ -24,11 +24,13 @@ describe BadgeGranter do it 'sets granted_by if the option is present' do admin = Fabricate(:admin) + StaffActionLogger.any_instance.expects(:log_badge_grant).once user_badge = BadgeGranter.grant(badge, user, granted_by: admin) user_badge.granted_by.should eq(admin) end it 'defaults granted_by to the system user' do + StaffActionLogger.any_instance.expects(:log_badge_grant).never user_badge = BadgeGranter.grant(badge, user) user_badge.granted_by_id.should eq(Discourse.system_user.id) end @@ -47,11 +49,13 @@ describe BadgeGranter do describe 'revoke' do + let(:admin) { Fabricate(:admin) } let!(:user_badge) { BadgeGranter.grant(badge, user) } it 'revokes the badge and decrements grant_count' do badge.reload.grant_count.should eq(1) - BadgeGranter.revoke(user_badge) + StaffActionLogger.any_instance.expects(:log_badge_revoke).with(user_badge) + BadgeGranter.revoke(user_badge, revoked_by: admin) UserBadge.where(user: user, badge: badge).first.should_not be_present badge.reload.grant_count.should eq(0) end diff --git a/spec/services/staff_action_logger_spec.rb b/spec/services/staff_action_logger_spec.rb index 4ce04aabd66..b0466c022f3 100644 --- a/spec/services/staff_action_logger_spec.rb +++ b/spec/services/staff_action_logger_spec.rb @@ -150,4 +150,38 @@ describe StaffActionLogger do log_record.target_user.should == user end end + + describe "log_badge_grant" do + let(:user) { Fabricate(:user) } + let(:badge) { Fabricate(:badge) } + let(:user_badge) { BadgeGranter.grant(badge, user) } + + it "raises an error when argument is missing" do + expect { logger.log_badge_grant(nil) }.to raise_error(Discourse::InvalidParameters) + end + + it "creates a new UserHistory record" do + log_record = logger.log_badge_grant(user_badge) + log_record.should be_valid + log_record.target_user.should == user + log_record.details.should == badge.name + end + end + + describe "log_badge_revoke" do + let(:user) { Fabricate(:user) } + let(:badge) { Fabricate(:badge) } + let(:user_badge) { BadgeGranter.grant(badge, user) } + + it "raises an error when argument is missing" do + expect { logger.log_badge_revoke(nil) }.to raise_error(Discourse::InvalidParameters) + end + + it "creates a new UserHistory record" do + log_record = logger.log_badge_revoke(user_badge) + log_record.should be_valid + log_record.target_user.should == user + log_record.details.should == badge.name + end + end end diff --git a/test/javascripts/models/user_badge_test.js b/test/javascripts/models/user_badge_test.js index 306549ad68f..eb56100df94 100644 --- a/test/javascripts/models/user_badge_test.js +++ b/test/javascripts/models/user_badge_test.js @@ -1,9 +1,10 @@ module("Discourse.UserBadge"); -test('createFromJson single', function() { - var json = {"badges":[{"id":874,"name":"Badge 2","description":null,"badge_type_id":7}],"badge_types":[{"id":7,"name":"Silver 2","color_hexcode":"#c0c0c0"}],"users":[{"id":13470,"username":"anne3","avatar_template":"//www.gravatar.com/avatar/a4151b1fd72089c54e2374565a87da7f.png?s={size}\u0026r=pg\u0026d=identicon"}],"user_badge":{"id":665,"granted_at":"2014-03-09T20:30:01.190-04:00","badge_id":874,"granted_by_id":13470}}; +var singleBadgeJson = {"badges":[{"id":874,"name":"Badge 2","description":null,"badge_type_id":7}],"badge_types":[{"id":7,"name":"Silver 2","color_hexcode":"#c0c0c0"}],"users":[{"id":13470,"username":"anne3","avatar_template":"//www.gravatar.com/avatar/a4151b1fd72089c54e2374565a87da7f.png?s={size}\u0026r=pg\u0026d=identicon"}],"user_badge":{"id":665,"granted_at":"2014-03-09T20:30:01.190-04:00","badge_id":874,"granted_by_id":13470}}, + multipleBadgesJson = {"badges":[{"id":880,"name":"Badge 8","description":null,"badge_type_id":13}],"badge_types":[{"id":13,"name":"Silver 8","color_hexcode":"#c0c0c0"}],"users":[],"user_badges":[{"id":668,"granted_at":"2014-03-09T20:30:01.420-04:00","badge_id":880,"granted_by_id":null}]}; - var userBadge = Discourse.UserBadge.createFromJson(json); +test('createFromJson single', function() { + var userBadge = Discourse.UserBadge.createFromJson(singleBadgeJson); ok(!Array.isArray(userBadge), "does not return an array"); equal(userBadge.get('badge.name'), "Badge 2", "badge reference is set"); equal(userBadge.get('badge.badge_type.name'), "Silver 2", "badge.badge_type reference is set"); @@ -11,9 +12,30 @@ test('createFromJson single', function() { }); test('createFromJson array', function() { - var json = {"badges":[{"id":880,"name":"Badge 8","description":null,"badge_type_id":13}],"badge_types":[{"id":13,"name":"Silver 8","color_hexcode":"#c0c0c0"}],"users":[],"user_badges":[{"id":668,"granted_at":"2014-03-09T20:30:01.420-04:00","badge_id":880,"granted_by_id":null}]}; - - var userBadges = Discourse.UserBadge.createFromJson(json); + var userBadges = Discourse.UserBadge.createFromJson(multipleBadgesJson); ok(Array.isArray(userBadges), "returns an array"); equal(userBadges[0].get('granted_by'), null, "granted_by reference is not set when null"); }); + +test('findByUsername', function() { + this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve(multipleBadgesJson)); + Discourse.UserBadge.findByUsername("anne3").then(function(badges) { + ok(Array.isArray(badges), "returns an array"); + }); + ok(Discourse.ajax.calledOnce, "makes an AJAX call"); +}); + +test('grant', function() { + this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve(singleBadgeJson)); + Discourse.UserBadge.grant(1, "username").then(function(userBadge) { + ok(!Array.isArray(userBadge), "does not return an array"); + }); + ok(Discourse.ajax.calledOnce, "makes an AJAX call"); +}); + +test('revoke', function() { + this.stub(Discourse, 'ajax'); + var userBadge = Discourse.UserBadge.create({id: 1}); + userBadge.revoke(); + ok(Discourse.ajax.calledOnce, "makes an AJAX call"); +});