Merge pull request #2372 from vikhyat/badge-system

Multiple grant badges
This commit is contained in:
Sam 2014-05-30 10:18:00 +10:00
commit fe594f5d1e
25 changed files with 171 additions and 42 deletions

View File

@ -9,14 +9,54 @@
Discourse.AdminBadgesController = Ember.ArrayController.extend({ Discourse.AdminBadgesController = Ember.ArrayController.extend({
itemController: 'adminBadge', itemController: 'adminBadge',
queryParams: ['badgeId'], queryParams: ['badgeId'],
badgeId: Em.computed.alias('selectedId'),
/** /**
ID of the currently selected badge. ID of the currently selected badge.
@property badgeId @property selectedId
@type {Integer} @type {Integer}
**/ **/
badgeId: Em.computed.alias('selectedItem.id'), selectedId: null,
/**
Badge that is currently selected.
@property selectedItem
@type {Discourse.Badge}
**/
selectedItem: function() {
if (this.get('selectedId') === undefined || this.get('selectedId') === "undefined") {
// New Badge
return this.get('newBadge');
} else {
// Existing Badge
var selectedId = parseInt(this.get('selectedId'));
return this.get('model').filter(function(badge) {
return parseInt(badge.get('id')) === selectedId;
})[0];
}
}.property('selectedId', 'newBadge'),
/**
Unsaved badge, if one exists.
@property newBadge
@type {Discourse.Badge}
**/
newBadge: function() {
return this.get('model').filter(function(badge) {
return badge.get('id') === undefined;
})[0];
}.property('model.@each.id'),
/**
Whether a new unsaved badge exists.
@property newBadgeExists
@type {Discourse.Badge}
**/
newBadgeExists: Em.computed.notEmpty('newBadge'),
/** /**
We don't allow setting a description if a translation for the given badge We don't allow setting a description if a translation for the given badge
@ -42,7 +82,7 @@ Discourse.AdminBadgesController = Ember.ArrayController.extend({
@method newBadge @method newBadge
**/ **/
newBadge: function() { createNewBadge: function() {
var badge = Discourse.Badge.create({ var badge = Discourse.Badge.create({
name: I18n.t('admin.badges.new_badge') name: I18n.t('admin.badges.new_badge')
}); });
@ -57,7 +97,7 @@ Discourse.AdminBadgesController = Ember.ArrayController.extend({
@param {Discourse.Badge} badge The badge to be selected @param {Discourse.Badge} badge The badge to be selected
**/ **/
selectBadge: function(badge) { selectBadge: function(badge) {
this.set('selectedItem', badge); this.set('selectedId', badge.get('id'));
}, },
/** /**
@ -80,7 +120,7 @@ Discourse.AdminBadgesController = Ember.ArrayController.extend({
// Delete immediately if the selected badge is new. // Delete immediately if the selected badge is new.
if (!this.get('selectedItem.id')) { if (!this.get('selectedItem.id')) {
this.get('model').removeObject(this.get('selectedItem')); this.get('model').removeObject(this.get('selectedItem'));
this.set('selectedItem', null); this.set('selectedId', null);
return; return;
} }
@ -90,7 +130,7 @@ Discourse.AdminBadgesController = Ember.ArrayController.extend({
var selected = self.get('selectedItem'); var selected = self.get('selectedItem');
selected.destroy().then(function() { selected.destroy().then(function() {
// Success. // Success.
self.set('selectedItem', null); self.set('selectedId', null);
self.get('model').removeObject(selected); self.get('model').removeObject(selected);
}, function() { }, function() {
// Failure. // Failure.

View File

@ -27,7 +27,7 @@ Discourse.AdminUserBadgesController = Ember.ArrayController.extend({
var badges = []; var badges = [];
this.get('badges').forEach(function(badge) { this.get('badges').forEach(function(badge) {
if (!granted[badge.get('id')]) { if (badge.get('multiple_grant') || !granted[badge.get('id')]) {
badges.push(badge); badges.push(badge);
} }
}); });

View File

@ -14,7 +14,7 @@
</li> </li>
{{/each}} {{/each}}
</ul> </ul>
<button {{action newBadge}} class='btn'><i class="fa fa-plus"></i>{{i18n admin.badges.new}}</button> <button {{action createNewBadge}} {{bind-attr disabled=newBadgeExists}} class='btn'><i class="fa fa-plus"></i>{{i18n admin.badges.new}}</button>
</div> </div>
{{#if selectedItem}} {{#if selectedItem}}
@ -47,7 +47,7 @@
{{#if controller.canEditDescription}} {{#if controller.canEditDescription}}
{{textarea name="description" value=description}} {{textarea name="description" value=description}}
{{else}} {{else}}
{{textarea name="description" value=translatedDescription disabled=true}} {{textarea name="description" value=displayDescription disabled=true}}
{{/if}} {{/if}}
</div> </div>
@ -58,6 +58,13 @@
</span> </span>
</div> </div>
<div>
<span>
{{input type="checkbox" checked=multiple_grant disabled=readOnly}}
{{i18n admin.badges.multiple_grant}}
</span>
</div>
{{#unless readOnly}} {{#unless readOnly}}
<div class='buttons'> <div class='buttons'>
<button {{action save}} {{bind-attr disabled=controller.disableSave}} class='btn btn-primary'>{{i18n admin.badges.save}}</button> <button {{action save}} {{bind-attr disabled=controller.disableSave}} class='btn btn-primary'>{{i18n admin.badges.save}}</button>

View File

@ -3,5 +3,9 @@ Discourse.UserBadgeComponent = Ember.Component.extend({
badgeTypeClassName: function() { badgeTypeClassName: function() {
return "badge-type-" + this.get('badge.badge_type.name').toLowerCase(); return "badge-type-" + this.get('badge.badge_type.name').toLowerCase();
}.property('badge.badge_type.name') }.property('badge.badge_type.name'),
showGrantCount: function() {
return this.get('count') && this.get('count') > 1;
}.property('count')
}); });

View File

@ -40,8 +40,8 @@ Discourse.Badge = Discourse.Model.extend({
}.property('name', 'i18nNameKey'), }.property('name', 'i18nNameKey'),
/** /**
The i18n translated description for this badge. Returns the original The i18n translated description for this badge. Returns the null if no
description if no translation exists. translation exists.
@property translatedDescription @property translatedDescription
@type {String} @type {String}
@ -50,11 +50,23 @@ Discourse.Badge = Discourse.Model.extend({
var i18nKey = "badges.badge." + this.get('i18nNameKey') + ".description", var i18nKey = "badges.badge." + this.get('i18nNameKey') + ".description",
translation = I18n.t(i18nKey); translation = I18n.t(i18nKey);
if (translation.indexOf(i18nKey) !== -1) { if (translation.indexOf(i18nKey) !== -1) {
translation = this.get('description'); translation = null;
} }
return translation; return translation;
}.property('i18nNameKey'), }.property('i18nNameKey'),
/**
Display-friendly description string. Returns either a translation or the
original description string.
@property displayDescription
@type {String}
**/
displayDescription: function() {
var translated = this.get('translatedDescription');
return translated === null ? this.get('description') : translated;
}.property('description', 'translatedDescription'),
/** /**
Update this badge with the response returned by the server on save. Update this badge with the response returned by the server on save.
@ -103,7 +115,8 @@ Discourse.Badge = Discourse.Model.extend({
name: this.get('name'), name: this.get('name'),
description: this.get('description'), description: this.get('description'),
badge_type_id: this.get('badge_type_id'), badge_type_id: this.get('badge_type_id'),
allow_title: this.get('allow_title') allow_title: !!this.get('allow_title'),
multiple_grant: !!this.get('multiple_grant')
} }
}).then(function(json) { }).then(function(json) {
self.updateFromJson(json); self.updateFromJson(json);

View File

@ -79,10 +79,15 @@ Discourse.UserBadge.reopenClass({
@method findByUsername @method findByUsername
@param {String} username @param {String} username
@param {Object} options
@returns {Promise} a promise that resolves to an array of `Discourse.UserBadge`. @returns {Promise} a promise that resolves to an array of `Discourse.UserBadge`.
**/ **/
findByUsername: function(username) { findByUsername: function(username, options) {
return Discourse.ajax("/user_badges.json?username=" + username).then(function(json) { var url = "/user_badges.json?username=" + username;
if (options && options.aggregated) {
url += "&aggregated=true";
}
return Discourse.ajax(url).then(function(json) {
return Discourse.UserBadge.createFromJson(json); return Discourse.UserBadge.createFromJson(json);
}); });
}, },

View File

@ -8,7 +8,7 @@
**/ **/
Discourse.UserBadgesRoute = Discourse.Route.extend({ Discourse.UserBadgesRoute = Discourse.Route.extend({
model: function() { model: function() {
return Discourse.UserBadge.findByUsername(this.modelFor('user').get('username_lower')); return Discourse.UserBadge.findByUsername(this.modelFor('user').get('username_lower'), {aggregated: true});
}, },
setupController: function(controller, model) { setupController: function(controller, model) {

View File

@ -5,7 +5,7 @@
{{#each}} {{#each}}
<tr> <tr>
<td class='badge'>{{user-badge badge=this}}</td> <td class='badge'>{{user-badge badge=this}}</td>
<td class='description'>{{translatedDescription}}</td> <td class='description'>{{displayDescription}}</td>
<td class='grant-count'>{{i18n badges.granted count=grant_count}}</td> <td class='grant-count'>{{i18n badges.granted count=grant_count}}</td>
</tr> </tr>
{{/each}} {{/each}}

View File

@ -8,7 +8,7 @@
<table class='badges-listing'> <table class='badges-listing'>
<tr> <tr>
<td class='badge'>{{user-badge badge=this}}</td> <td class='badge'>{{user-badge badge=this}}</td>
<td class='description'>{{translatedDescription}}</td> <td class='description'>{{displayDescription}}</td>
<td class='grant-count'>{{i18n badges.granted count=grant_count}}</td> <td class='grant-count'>{{i18n badges.granted count=grant_count}}</td>
</tr> </tr>
</table> </table>

View File

@ -1,6 +1,9 @@
{{#link-to 'badges.show' badge}} {{#link-to 'badges.show' badge}}
<span {{bind-attr class=":user-badge badgeTypeClassName" data-badge-name="badge.name" title="badge.translatedDescription"}}> <span {{bind-attr class=":user-badge badgeTypeClassName" data-badge-name="badge.name" title="badge.displayDescription"}}>
<i class='fa fa-certificate'></i> <i class='fa fa-certificate'></i>
{{badge.displayName}} {{badge.displayName}}
{{#if showGrantCount}}
<span class="count">(&times;&nbsp;{{count}})</span>
{{/if}}
</span> </span>
{{/link-to}} {{/link-to}}

View File

@ -1,5 +1,5 @@
<section class='user-content user-badges-list'> <section class='user-content user-badges-list'>
{{#each}} {{#each}}
{{user-badge badge=badge}} {{user-badge badge=badge count=count}}
{{/each}} {{/each}}
</section> </section>

View File

@ -44,6 +44,12 @@
font-size: 50px; font-size: 50px;
margin-bottom: 5px; margin-bottom: 5px;
} }
.count {
display: block;
font-size: 0.8em;
color: scale-color($primary, $lightness: 50%);
}
} }
} }

View File

@ -30,11 +30,12 @@ class Admin::BadgesController < Admin::AdminController
end end
def update_badge_from_params(badge) def update_badge_from_params(badge)
params.permit(:name, :description, :badge_type_id, :allow_title) params.permit(:name, :description, :badge_type_id, :allow_title, :multiple_grant)
badge.name = params[:name] badge.name = params[:name]
badge.description = params[:description] badge.description = params[:description]
badge.badge_type = BadgeType.find(params[:badge_type_id]) badge.badge_type = BadgeType.find(params[:badge_type_id])
badge.allow_title = params[:allow_title] badge.allow_title = params[:allow_title]
badge.multiple_grant = params[:multiple_grant]
badge badge
end end
end end

View File

@ -16,6 +16,10 @@ class UserBadgesController < ApplicationController
user_badges = user_badges.includes(:user, :granted_by, badge: :badge_type) user_badges = user_badges.includes(:user, :granted_by, badge: :badge_type)
if params[:aggregated]
user_badges = user_badges.group(:badge_id).select(UserBadge.attribute_names.map {|x| "MAX(#{x}) as #{x}" }, 'COUNT(*) as count')
end
render_serialized(user_badges, UserBadgeSerializer, root: "user_badges") render_serialized(user_badges, UserBadgeSerializer, root: "user_badges")
end end

View File

@ -5,6 +5,7 @@ class Badge < ActiveRecord::Base
validates :name, presence: true, uniqueness: true validates :name, presence: true, uniqueness: true
validates :badge_type, presence: true validates :badge_type, presence: true
validates :allow_title, inclusion: [true, false] validates :allow_title, inclusion: [true, false]
validates :multiple_grant, inclusion: [true, false]
def self.trust_level_badge_ids def self.trust_level_badge_ids
(1..4).to_a (1..4).to_a
@ -15,22 +16,28 @@ class Badge < ActiveRecord::Base
save! save!
end end
def single_grant?
!self.multiple_grant?
end
end end
# == Schema Information # == Schema Information
# #
# Table name: badges # Table name: badges
# #
# id :integer not null, primary key # id :integer not null, primary key
# name :string(255) not null # name :string(255) not null
# description :text # description :text
# badge_type_id :integer not null # badge_type_id :integer not null
# grant_count :integer default(0), not null # grant_count :integer default(0), not null
# created_at :datetime # created_at :datetime
# updated_at :datetime # updated_at :datetime
# allow_title :boolean default(FALSE), not null # allow_title :boolean default(FALSE), not null
# multiple_grant :boolean default(FALSE), not null
# #
# Indexes # Indexes
# #
# index_badges_on_name (name) UNIQUE # index_badges_on_badge_type_id (badge_type_id)
# index_badges_on_name (name) UNIQUE
# #

View File

@ -3,7 +3,7 @@ class UserBadge < ActiveRecord::Base
belongs_to :user belongs_to :user
belongs_to :granted_by, class_name: 'User' belongs_to :granted_by, class_name: 'User'
validates :badge_id, presence: true, uniqueness: {scope: :user_id} validates :badge_id, presence: true, uniqueness: {scope: :user_id}, if: 'badge.single_grant?'
validates :user_id, presence: true validates :user_id, presence: true
validates :granted_at, presence: true validates :granted_at, presence: true
validates :granted_by, presence: true validates :granted_by, presence: true
@ -21,5 +21,6 @@ end
# #
# Indexes # Indexes
# #
# index_user_badges_on_badge_id_and_user_id (badge_id,user_id) UNIQUE # index_user_badges_on_badge_id_and_user_id (badge_id,user_id)
# index_user_badges_on_user_id (user_id)
# #

View File

@ -1,5 +1,5 @@
class BadgeSerializer < ApplicationSerializer class BadgeSerializer < ApplicationSerializer
attributes :id, :name, :description, :grant_count, :allow_title attributes :id, :name, :description, :grant_count, :allow_title, :multiple_grant
has_one :badge_type has_one :badge_type
end end

View File

@ -1,7 +1,11 @@
class UserBadgeSerializer < ApplicationSerializer class UserBadgeSerializer < ApplicationSerializer
attributes :id, :granted_at attributes :id, :granted_at, :count
has_one :badge has_one :badge
has_one :user, serializer: BasicUserSerializer, root: :users has_one :user, serializer: BasicUserSerializer, root: :users
has_one :granted_by, serializer: BasicUserSerializer, root: :users has_one :granted_by, serializer: BasicUserSerializer, root: :users
def include_count?
object.respond_to? :count
end
end end

View File

@ -14,7 +14,7 @@ class BadgeGranter
user_badge = UserBadge.find_by(badge_id: @badge.id, user_id: @user.id) user_badge = UserBadge.find_by(badge_id: @badge.id, user_id: @user.id)
unless user_badge if user_badge.nil? || @badge.multiple_grant?
UserBadge.transaction do UserBadge.transaction do
user_badge = UserBadge.create!(badge: @badge, user: @user, user_badge = UserBadge.create!(badge: @badge, user: @user,
granted_by: @granted_by, granted_at: Time.now) granted_by: @granted_by, granted_at: Time.now)

View File

@ -1819,6 +1819,7 @@ en:
no_user_badges: "%{name} has not been granted any badges." no_user_badges: "%{name} has not been granted any badges."
no_badges: There are no badges that can be granted. no_badges: There are no badges that can be granted.
allow_title: Allow badge to be used as a title allow_title: Allow badge to be used as a title
multiple_grant: Can be granted multiple times
lightbox: lightbox:
download: "download" download: "download"

View File

@ -0,0 +1,17 @@
class AddMultipleAwardToBadges < ActiveRecord::Migration
def change
add_column :badges, :multiple_grant, :boolean, default: false, null: false
reversible do |dir|
dir.up do
remove_index :user_badges, column: [:badge_id, :user_id]
add_index :user_badges, [:badge_id, :user_id]
end
dir.down do
remove_index :user_badges, column: [:badge_id, :user_id]
add_index :user_badges, [:badge_id, :user_id], unique: true
end
end
end
end

View File

@ -35,12 +35,12 @@ describe Admin::BadgesController do
context '.update' do context '.update' do
it 'returns success' do it 'returns success' do
xhr :put, :update, id: badge.id, name: "123456", badge_type_id: badge.badge_type_id, allow_title: false xhr :put, :update, id: badge.id, name: "123456", badge_type_id: badge.badge_type_id, allow_title: false, multiple_grant: false
response.should be_success response.should be_success
end end
it 'updates the badge' do it 'updates the badge' do
xhr :put, :update, id: badge.id, name: "123456", badge_type_id: badge.badge_type_id, allow_title: false xhr :put, :update, id: badge.id, name: "123456", badge_type_id: badge.badge_type_id, allow_title: false, multiple_grant: true
badge.reload.name.should eq('123456') badge.reload.name.should eq('123456')
end end
end end

View File

@ -26,6 +26,14 @@ describe UserBadgesController do
parsed = JSON.parse(response.body) parsed = JSON.parse(response.body)
parsed["user_badges"].length.should == 1 parsed["user_badges"].length.should == 1
end end
it 'includes counts when passed the aggregate argument' do
xhr :get, :index, username: user.username, aggregated: true
response.status.should == 200
parsed = JSON.parse(response.body)
parsed["user_badges"].first.has_key?('count').should be_true
end
end end
context 'create' do context 'create' do

View File

@ -15,11 +15,10 @@ test("canEditDescription", function() {
ok(!controller.get('canEditDescription'), "shows the displayName when it is different from the name"); ok(!controller.get('canEditDescription'), "shows the displayName when it is different from the name");
}); });
test("newBadge", function() { test("createNewBadge", function() {
var controller = testController(Discourse.AdminBadgesController, []); var controller = testController(Discourse.AdminBadgesController, []);
controller.send('newBadge'); controller.send('createNewBadge');
equal(controller.get('model.length'), 1, "adds a new badge to the list of badges"); equal(controller.get('model.length'), 1, "adds a new badge to the list of badges");
equal(controller.get('model')[0], controller.get('selectedItem'), "the new badge is selected");
}); });
test("selectBadge", function() { test("selectBadge", function() {

View File

@ -18,13 +18,22 @@ test('displayName', function() {
test('translatedDescription', function() { test('translatedDescription', function() {
var badge1 = Discourse.Badge.create({id: 1, name: "Test Badge 1", description: "TEST"}); var badge1 = Discourse.Badge.create({id: 1, name: "Test Badge 1", description: "TEST"});
equal(badge1.get('translatedDescription'), "TEST", "returns original description when no translation exists"); equal(badge1.get('translatedDescription'), null, "returns null when no translation exists");
var badge2 = Discourse.Badge.create({id: 2, name: "Test Badge 2 **"}); var badge2 = Discourse.Badge.create({id: 2, name: "Test Badge 2 **"});
this.stub(I18n, "t").returns("description translation"); this.stub(I18n, "t").returns("description translation");
equal(badge2.get('translatedDescription'), "description translation", "users translated description"); equal(badge2.get('translatedDescription'), "description translation", "users translated description");
}); });
test('displayDescription', function() {
var badge1 = Discourse.Badge.create({id: 1, name: "Test Badge 1", description: "TEST"});
equal(badge1.get('displayDescription'), "TEST", "returns original description when no translation exists");
var badge2 = Discourse.Badge.create({id: 2, name: "Test Badge 2 **"});
this.stub(I18n, "t").returns("description translation");
equal(badge2.get('displayDescription'), "description translation", "users translated description");
});
test('createFromJson array', function() { test('createFromJson array', function() {
var badgesJson = {"badge_types":[{"id":6,"name":"Silver 1"}],"badges":[{"id":1126,"name":"Badge 1","description":null,"badge_type_id":6}]}; var badgesJson = {"badge_types":[{"id":6,"name":"Silver 1"}],"badges":[{"id":1126,"name":"Badge 1","description":null,"badge_type_id":6}]};