Merge pull request #2115 from vikhyat/badge-system

Initial badge system implementation
This commit is contained in:
Sam 2014-03-17 10:06:37 +11:00
commit fe63db7953
41 changed files with 1186 additions and 0 deletions

View file

@ -0,0 +1,19 @@
/**
This is the itemController for `Discourse.AdminBadgesController`. Its main purpose
is to indicate which badge was selected.
@class AdminBadgeController
@extends Discourse.ObjectController
@namespace Discourse
@module Discourse
**/
Discourse.AdminBadgeController = Discourse.ObjectController.extend({
/**
Whether this badge has been selected.
@property selected
@type {Boolean}
**/
selected: Discourse.computed.propertyEqual('model.name', 'parentController.selectedItem.name')
});

View file

@ -0,0 +1,91 @@
/**
This controller supports the interface for dealing with badges.
@class AdminBadgesController
@extends Ember.ArrayController
@namespace Discourse
@module Discourse
**/
Discourse.AdminBadgesController = Ember.ArrayController.extend({
itemController: 'adminBadge',
/**
Show the displayName only if it is different from the name.
@property showDisplayName
@type {Boolean}
**/
showDisplayName: Discourse.computed.propertyNotEqual('selectedItem.name', 'selectedItem.displayName'),
/**
We don't allow setting a description if a translation for the given badge name
exists.
@property canEditDescription
@type {Boolean}
**/
canEditDescription: Em.computed.none('selectedItem.translatedDescription'),
actions: {
/**
Create a new badge and select it.
@method newBadge
**/
newBadge: function() {
var badge = Discourse.Badge.create({
name: I18n.t('admin.badges.new_badge')
});
this.pushObject(badge);
this.send('selectBadge', badge);
},
/**
Select a particular badge.
@method selectBadge
@param {Discourse.Badge} badge The badge to be selected
**/
selectBadge: function(badge) {
this.set('selectedItem', badge);
},
/**
Save the selected badge.
@method save
**/
save: function() {
var badge = this.get('selectedItem');
badge.set('disableSave', true);
badge.save().then(function() {
badge.set('disableSave', false);
});
},
/**
Confirm before destroying the selected badge.
@method destroy
**/
destroy: function() {
var self = this;
return bootbox.confirm(I18n.t("admin.badges.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
if (result) {
var selected = self.get('selectedItem');
selected.destroy().then(function() {
// Success.
self.set('selectedItem', null);
self.get('model').removeObject(selected);
}, function() {
// Failure.
bootbox.alert(I18n.t('generic_error'));
});
}
});
}
}
});

View file

@ -24,6 +24,10 @@ Discourse.AdminUserIndexController = Discourse.ObjectController.extend({
return Discourse.SiteSettings.must_approve_users;
}.property(),
showBadges: function() {
return Discourse.SiteSettings.enable_badges;
}.property(),
primaryGroupDirty: Discourse.computed.propertyNotEqual('originalPrimaryGroupId', 'primary_group_id'),
actions: {

View file

@ -0,0 +1,14 @@
Discourse.AdminBadgesRoute = Discourse.Route.extend({
model: function() {
return Discourse.Badge.findAll();
},
setupController: function(controller, model) {
Discourse.ajax('/admin/badges/types').then(function(json) {
controller.set('badgeTypes', json.badge_types);
});
controller.set('model', model);
}
});

View file

@ -57,5 +57,7 @@ Discourse.Route.buildRoutes(function() {
});
});
this.route('badges');
});
});

View file

@ -24,6 +24,7 @@
<li>{{#link-to 'admin.customize'}}{{i18n admin.customize.title}}{{/link-to}}</li>
<li>{{#link-to 'admin.api'}}{{i18n admin.api.title}}{{/link-to}}</li>
<li>{{#link-to 'admin.backups'}}{{i18n admin.backups.title}}{{/link-to}}</li>
<li>{{#link-to 'admin.badges'}}{{i18n admin.badges.title}}{{/link-to}}</li>
{{/if}}
</ul>

View file

@ -0,0 +1,62 @@
<div class="badges">
<div class='content-list span6'>
<h3>{{i18n admin.badges.title}}</h3>
<ul>
{{#each}}
<li>
<a {{action selectBadge this}} {{bind-attr class="selected:active"}}>
{{displayName}}
{{#if newBadge}}
(*)
{{/if}}
</a>
</li>
{{/each}}
</ul>
<button {{action newBadge}} class='btn'>{{i18n admin.badges.new}}</button>
</div>
{{#if selectedItem}}
{{#with selectedItem}}
<div class='current-badge span12'>
<form class="form-horizontal">
<div>
<label for="name">{{i18n admin.badges.name}}</label>
{{input type="text" name="name" value=name}}
</div>
{{#if controller.showDisplayName}}
<div>
<strong>{{i18n admin.badges.display_name}}</strong>
{{displayName}}
</div>
{{/if}}
<div>
<label for="badge_type_id">{{i18n admin.badges.badge_type}}</label>
{{view Ember.Select name="badge_type_id" value=badge_type_id
content=controller.badgeTypes
optionValuePath="content.id"
optionLabelPath="content.name"}}
</div>
<div>
<label for="description">{{i18n admin.badges.description}}</label>
{{#if controller.canEditDescription}}
{{textarea name="description" value=description}}
{{else}}
{{textarea name="description" value=translatedDescription disabled=true}}
{{/if}}
</div>
<div class='buttons'>
<button {{action save}} {{bind-attr disabled=disableSave}} class='btn btn-primary'>{{i18n admin.badges.save}}</button>
<a {{action destroy}} class='delete-link'>{{i18n admin.badges.delete}}</a>
</div>
</form>
</div>
{{/with}}
{{/if}}
</div>

View file

@ -336,6 +336,12 @@
</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"}}>

View file

@ -0,0 +1,164 @@
/**
A data model representing a badge on Discourse
@class Badge
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.Badge = Discourse.Model.extend({
/**
Is this a new badge?
@property newBadge
@type {String}
**/
newBadge: Em.computed.none('id'),
/**
@private
The name key to use for fetching i18n translations.
@property i18nNameKey
@type {String}
**/
i18nNameKey: function() {
return this.get('name').toLowerCase().replace(/\s/g, '_');
}.property('name'),
/**
The display name of this badge. Attempts to use a translation and falls back to
the actual name.
@property displayName
@type {String}
**/
displayName: function() {
var i18nKey = "badges." + this.get('i18nNameKey') + ".name";
return I18n.t(i18nKey, {defaultValue: this.get('name')});
}.property('name', 'i18nNameKey'),
/**
The i18n translated description for this badge. `null` if no translation exists.
@property translatedDescription
@type {String}
**/
translatedDescription: function() {
var i18nKey = "badges." + this.get('i18nNameKey') + ".description",
translation = I18n.t(i18nKey);
if (translation.match(new RegExp(i18nKey))) {
translation = null;
}
return translation;
}.property('i18nNameKey'),
/**
Update this badge with the response returned by the server on save.
@method updateFromJson
@param {Object} json The JSON response returned by the server
**/
updateFromJson: function(json) {
var self = this;
Object.keys(json.badge).forEach(function(key) {
self.set(key, json.badge[key]);
});
json.badge_types.forEach(function(badgeType) {
if (badgeType.id === self.get('badge_type_id')) {
self.set('badge_type', Object.create(badgeType));
}
});
},
/**
Save and update the badge from the server's response.
@method save
@returns {Promise} A promise that resolves to the updated `Discourse.Badge`
**/
save: function() {
var url = "/admin/badges",
requestType = "POST",
self = this;
if (!this.get('newBadge')) {
// We are updating an existing badge.
url += "/" + this.get('id');
requestType = "PUT";
}
return Discourse.ajax(url, {
type: requestType,
data: {
name: this.get('name'),
description: this.get('description'),
badge_type_id: this.get('badge_type_id')
}
}).then(function(json) {
self.updateFromJson(json);
return self;
});
},
/**
Destroy the badge.
@method destroy
@returns {Promise} A promise that resolves to the server response
**/
destroy: function() {
if (this.get('newBadge')) return Ember.RSVP.resolve();
return Discourse.ajax("/admin/badges/" + this.get('id'), {
type: "DELETE"
});
}
});
Discourse.Badge.reopenClass({
/**
Create `Discourse.Badge` instances from the server JSON response.
@method createFromJson
@param {Object} json The JSON returned by the server
@returns Array or instance of `Discourse.Badge` depending on the input JSON
**/
createFromJson: function(json) {
// Create BadgeType objects.
var badgeTypes = {};
if ('badge_types' in json) {
json.badge_types.forEach(function(badgeTypeJson) {
badgeTypes[badgeTypeJson.id] = Ember.Object.create(badgeTypeJson);
});
}
// Create Badge objects.
var badges = [];
if ("badge" in json) {
badges = [json.badge];
} else {
badges = json.badges;
}
badges = badges.map(function(badgeJson) {
var badge = Discourse.Badge.create(badgeJson);
badge.set('badge_type', badgeTypes[badge.get('badge_type_id')]);
return badge;
});
if ("badge" in json) {
return badges[0];
} else {
return badges;
}
},
/**
Find all `Discourse.Badge` instances that have been defined.
@method findAll
@returns {Promise} a promise that resolves to an array of `Discourse.Badge`
**/
findAll: function() {
return Discourse.ajax('/admin/badges').then(function(badgesJson) {
return Discourse.Badge.createFromJson(badgesJson);
});
}
});

View file

@ -0,0 +1,57 @@
/**
A data model representing a user badge grant on Discourse
@class UserBadge
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
Discourse.UserBadge = Discourse.Model.extend({
});
Discourse.UserBadge.reopenClass({
/**
Create `Discourse.UserBadge` instances from the server JSON response.
@method createFromJson
@param {Object} json The JSON returned by the server
@returns Array or instance of `Discourse.UserBadge` depending on the input JSON
**/
createFromJson: function(json) {
// Create User objects.
var users = {};
json.users.forEach(function(userJson) {
users[userJson.id] = Discourse.User.create(userJson);
});
// Create the badges.
var badges = {};
Discourse.Badge.createFromJson(json).forEach(function(badge) {
badges[badge.get('id')] = badge;
});
// Create UserBadge object(s).
var userBadges = [];
if ("user_badge" in json) {
userBadges = [json.user_badge];
} else {
userBadges = json.user_badges;
}
userBadges = userBadges.map(function(userBadgeJson) {
var userBadge = Discourse.UserBadge.create(userBadgeJson);
userBadge.set('badge', badges[userBadge.get('badge_id')]);
if (userBadge.get('granted_by_id')) {
userBadge.set('granted_by', users[userBadge.get('granted_by_id')]);
}
return userBadge;
});
if ("user_badge" in json) {
return userBadges[0];
} else {
return userBadges;
}
}
});

View file

@ -285,6 +285,30 @@ section.details {
}
}
// Badges area
.badges {
.content-list ul {
margin-bottom: 10px;
}
.current-badge {
margin: 20px;
}
.form-horizontal {
label {
font-weight: bold;
}
& > div {
margin-top: 10px;
}
.delete-link {
margin-left: 15px;
margin-top: 5px;
}
}
}
// Customise area
.customize {
.nav.nav-pills {

View file

@ -0,0 +1,44 @@
class Admin::BadgesController < Admin::AdminController
def index
badges = Badge.all.to_a
render_serialized(badges, BadgeSerializer, root: "badges")
end
def badge_types
badge_types = BadgeType.all.to_a
render_serialized(badge_types, BadgeTypeSerializer, root: "badge_types")
end
def create
badge = Badge.new
update_badge_from_params(badge)
badge.save!
render_serialized(badge, BadgeSerializer, root: "badge")
end
def update
badge = find_badge
update_badge_from_params(badge)
badge.save!
render_serialized(badge, BadgeSerializer, root: "badge")
end
def destroy
find_badge.destroy
render nothing: true
end
private
def find_badge
params.require(:id)
Badge.find(params[:id])
end
def update_badge_from_params(badge)
params.permit(:name, :description, :badge_type_id)
badge.name = params[:name]
badge.description = params[:description]
badge.badge_type = BadgeType.find(params[:badge_type_id])
badge
end
end

View file

@ -0,0 +1,58 @@
class UserBadgesController < ApplicationController
def index
params.require(:username)
user = fetch_user_from_params
render json: user.user_badges
end
def create
params.require(:username)
user = fetch_user_from_params
unless can_assign_badge_to_user?(user)
render json: failed_json, status: 403
return
end
badge = fetch_badge_from_params
user_badge = BadgeGranter.grant(badge, user, granted_by: current_user)
render json: user_badge
end
def destroy
params.require(:id)
user_badge = UserBadge.find(params[:id])
unless can_assign_badge_to_user?(user_badge.user)
render json: failed_json, status: 403
return
end
BadgeGranter.revoke(user_badge)
render json: success_json
end
private
# Get the badge from either the badge name or id specified in the params.
def fetch_badge_from_params
badge = nil
params.permit(:badge_name)
if params[:badge_name].nil?
params.require(:badge_id)
badge = Badge.where(id: params[:badge_id]).first
else
badge = Badge.where(name: params[:badge_name]).first
end
raise Discourse::NotFound.new if badge.blank?
badge
end
def can_assign_badge_to_user?(user)
master_api_call = current_user.nil? && is_api?
master_api_call or guardian.can_grant_badges?(user)
end
end

24
app/models/badge.rb Normal file
View file

@ -0,0 +1,24 @@
class Badge < ActiveRecord::Base
belongs_to :badge_type
validates :name, presence: true, uniqueness: true
validates :badge_type, presence: true
end
# == Schema Information
#
# Table name: badges
#
# id :integer not null, primary key
# name :string(255) not null
# description :text
# badge_type_id :integer not null
# grant_count :integer default(0), not null
# created_at :datetime
# updated_at :datetime
#
# Indexes
#
# index_badges_on_badge_type_id (badge_type_id)
# index_badges_on_name (name) UNIQUE
#

21
app/models/badge_type.rb Normal file
View file

@ -0,0 +1,21 @@
class BadgeType < ActiveRecord::Base
has_many :badges
validates :name, presence: true, uniqueness: true
validates :color_hexcode, presence: true
end
# == Schema Information
#
# Table name: badge_types
#
# id :integer not null, primary key
# name :string(255) not null
# color_hexcode :string(255) not null
# created_at :datetime
# updated_at :datetime
#
# Indexes
#
# index_badge_types_on_name (name) UNIQUE
#

View file

@ -21,6 +21,7 @@ class User < ActiveRecord::Base
has_many :user_open_ids, dependent: :destroy
has_many :user_actions, dependent: :destroy
has_many :post_actions, dependent: :destroy
has_many :user_badges, dependent: :destroy
has_many :email_logs, dependent: :destroy
has_many :post_timings
has_many :topic_allowed_users, dependent: :destroy

26
app/models/user_badge.rb Normal file
View file

@ -0,0 +1,26 @@
class UserBadge < ActiveRecord::Base
belongs_to :badge
belongs_to :user
belongs_to :granted_by, class_name: 'User'
validates :badge_id, presence: true, uniqueness: {scope: :user_id}
validates :user_id, presence: true
validates :granted_at, presence: true
validates :granted_by, presence: true
end
# == Schema Information
#
# Table name: user_badges
#
# id :integer not null, primary key
# badge_id :integer not null
# user_id :integer not null
# granted_at :datetime not null
# granted_by_id :integer not null
#
# Indexes
#
# index_user_badges_on_badge_id_and_user_id (badge_id,user_id) UNIQUE
# index_user_badges_on_user_id (user_id)
#

View file

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

View file

@ -0,0 +1,3 @@
class BadgeTypeSerializer < ApplicationSerializer
attributes :id, :name, :color_hexcode
end

View file

@ -0,0 +1,6 @@
class UserBadgeSerializer < ApplicationSerializer
attributes :id, :granted_at
has_one :badge
has_one :granted_by, serializer: BasicUserSerializer, root: :users
end

View file

@ -0,0 +1,34 @@
class BadgeGranter
def initialize(badge, user, opts={})
@badge, @user, @opts = badge, user, opts
@granted_by = opts[:granted_by] || Discourse.system_user
end
def self.grant(badge, user, opts={})
BadgeGranter.new(badge, user, opts).grant
end
def grant
return if @granted_by and !Guardian.new(@granted_by).can_grant_badges?(@user)
user_badge = nil
UserBadge.transaction do
user_badge = UserBadge.create!(badge: @badge, user: @user,
granted_by: @granted_by, granted_at: Time.now)
Badge.increment_counter 'grant_count', @badge.id
end
user_badge
end
def self.revoke(user_badge)
UserBadge.transaction do
user_badge.destroy!
Badge.decrement_counter 'grant_count', user_badge.badge.id
end
end
end

View file

@ -1667,6 +1667,18 @@ en:
uncategorized: 'Uncategorized'
backups: "Backups"
badges:
title: Badges
new_badge: New Badge
new: New
name: Name
display_name: Display Name
description: Description
badge_type: Badge Type
save: Save
delete: Delete
delete_confirm: Are you sure you want to delete this badge?
lightbox:
download: "download"
@ -1708,3 +1720,8 @@ en:
mark_regular: '<b>m</b> then <b>r</b> Mark topic as regular'
mark_tracking: '<b>m</b> then <b>t</b> Mark topic as tracking'
mark_watching: '<b>m</b> then <b>w</b> Mark topic as watching'
badges:
example_badge:
name: Example Badge
description: This is a generic example badge.

View file

@ -665,6 +665,8 @@ en:
topics_per_period_in_top_page: "How many topics loaded on the top topics page"
redirect_new_users_to_top_page_duration: "Number of days during which new users are automatically redirect to the top page"
enable_badges: "Enable the badge system (experimental)"
allow_index_in_robots_txt: "Site should be indexed by search engines (update robots.txt)"
email_domains_blacklist: "A pipe-delimited list of email domains that are not allowed. Example: mailinator.com|trashmail.net"
email_domains_whitelist: "A pipe-delimited list of email domains that users may register with. WARNING: Users with email domains other than those listed will not be allowed."
@ -1418,3 +1420,9 @@ en:
message_to_blank: "message.to is blank"
text_part_body_blank: "text_part.body is blank"
body_blank: "body is blank"
badges:
types:
gold: Gold
silver: Silver
bronze: Bronze

View file

@ -126,6 +126,12 @@ Discourse::Application.routes.draw do
end
end
resources :badges, constraints: AdminConstraint.new do
collection do
get "types" => "badges#badge_types"
end
end
get "memory_stats"=> "diagnostics#memory_stats", constraints: AdminConstraint.new
end # admin namespace
@ -235,6 +241,8 @@ Discourse::Application.routes.draw do
end
resources :user_actions
resources :user_badges, only: [:index, :create, :destroy]
# We've renamed popular to latest. If people access it we want a permanent redirect.
get "popular" => "list#popular_redirect"
get "popular/more" => "list#popular_redirect"

View file

@ -75,6 +75,9 @@ basic:
default: 50
redirect_new_users_to_top_page_duration:
default: 7
enable_badges:
client: true
default: false
users:
enable_sso:

View file

@ -0,0 +1,17 @@
BadgeType.seed do |b|
b.id = 1
b.name = I18n.t('badges.types.gold')
b.color_hexcode = "ffd700"
end
BadgeType.seed do |b|
b.id = 2
b.name = I18n.t('badges.types.silver')
b.color_hexcode = "c0c0c0"
end
BadgeType.seed do |b|
b.id = 3
b.name = I18n.t('badges.types.bronze')
b.color_hexcode = "cd7f32"
end

View file

@ -0,0 +1,12 @@
class CreateBadgeTypes < ActiveRecord::Migration
def change
create_table :badge_types do |t|
t.string :name, null: false
t.string :color_hexcode, null: false
t.timestamps
end
add_index :badge_types, [:name], unique: true
end
end

View file

@ -0,0 +1,14 @@
class CreateBadges < ActiveRecord::Migration
def change
create_table :badges do |t|
t.string :name, null: false
t.text :description
t.integer :badge_type_id, index: true, null: false
t.integer :grant_count, null: false, default: 0
t.timestamps
end
add_index :badges, [:name], unique: true
end
end

View file

@ -0,0 +1,12 @@
class CreateUserBadges < ActiveRecord::Migration
def change
create_table :user_badges do |t|
t.integer :badge_id, null: false
t.integer :user_id, index: true, null: false
t.datetime :granted_at, null: false
t.integer :granted_by_id, null: false
end
add_index :user_badges, [:badge_id, :user_id], unique: true
end
end

View file

@ -87,6 +87,7 @@ class Guardian
alias :can_move_posts? :can_moderate?
alias :can_see_flags? :can_moderate?
alias :can_send_activation_email? :can_moderate?
alias :can_grant_badges? :can_moderate?

View file

@ -0,0 +1,60 @@
require 'spec_helper'
describe Admin::BadgesController do
it "is a subclass of AdminController" do
(Admin::BadgesController < Admin::AdminController).should be_true
end
context "while logged in as an admin" do
let!(:user) { log_in(:admin) }
let!(:badge) { Fabricate(:badge) }
context '.index' do
it 'returns success' do
xhr :get, :index
response.should be_success
end
it 'returns JSON' do
xhr :get, :index
::JSON.parse(response.body)["badges"].should be_present
end
end
context '.badge_types' do
it 'returns success' do
xhr :get, :badge_types
response.should be_success
end
it 'returns JSON' do
xhr :get, :badge_types
::JSON.parse(response.body)["badge_types"].should be_present
end
end
context '.destroy' do
it 'returns success' do
xhr :delete, :destroy, id: badge.id
response.should be_success
end
it 'deletes the badge' do
xhr :delete, :destroy, id: badge.id
Badge.where(id: badge.id).count.should eq(0)
end
end
context '.update' do
it 'returns success' do
xhr :put, :update, id: badge.id, name: "123456", badge_type_id: badge.badge_type_id
response.should be_success
end
it 'updates the badge' do
xhr :put, :update, id: badge.id, name: "123456", badge_type_id: badge.badge_type_id
badge.reload.name.should eq('123456')
end
end
end
end

View file

@ -0,0 +1,79 @@
require 'spec_helper'
describe UserBadgesController do
let(:user) { Fabricate(:user) }
let(:badge) { Fabricate(:badge) }
context 'index' do
before do
@user_badge = BadgeGranter.grant(badge, user)
end
it 'requires username to be specified' do
expect { xhr :get, :index }.to raise_error
end
it 'returns the user\'s badges' do
xhr :get, :index, username: user.username
response.status.should == 200
parsed = JSON.parse(response.body)
parsed["user_badges"].length.should == 1
end
end
context 'create' do
it 'requires username to be specified' do
expect { xhr :post, :create, badge_id: badge.id }.to raise_error
end
it 'does not allow regular users to grant badges' do
log_in_user Fabricate(:user)
xhr :post, :create, badge_id: badge.id, username: user.username
response.status.should == 403
end
it 'grants badges from staff' do
admin = Fabricate(:admin)
log_in_user admin
xhr :post, :create, badge_id: badge.id, username: user.username
response.status.should == 200
user_badge = UserBadge.where(user: user, badge: badge).first
user_badge.should be_present
user_badge.granted_by.should eq(admin)
end
it 'does not grant badges from regular api calls' do
Fabricate(:api_key, user: user)
xhr :post, :create, badge_id: badge.id, username: user.username, api_key: user.api_key.key
response.status.should == 403
end
it 'grants badges from master api calls' do
api_key = Fabricate(:api_key)
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
user_badge.should be_present
user_badge.granted_by.should eq(Discourse.system_user)
end
end
context 'destroy' do
before do
@user_badge = BadgeGranter.grant(badge, user)
end
it 'checks that the user is authorized to revoke a badge' do
xhr :delete, :destroy, id: @user_badge.id
response.status.should == 403
end
it 'revokes the badge' do
log_in :admin
xhr :delete, :destroy, id: @user_badge.id
response.status.should == 200
UserBadge.where(id: @user_badge.id).first.should be_nil
end
end
end

View file

@ -0,0 +1,9 @@
Fabricator(:badge_type) do
name { sequence(:name) {|i| "Silver #{i}" } }
color_hexcode "c0c0c0"
end
Fabricator(:badge) do
name { sequence(:name) {|i| "Badge #{i}" } }
badge_type
end

17
spec/models/badge.rb Normal file
View file

@ -0,0 +1,17 @@
require 'spec_helper'
require_dependency 'badge'
describe Badge do
it { should belong_to :badge_type }
context 'validations' do
before(:each) { Fabricate(:badge) }
it { should validate_presence_of :name }
it { should validate_presence_of :badge_type }
it { should validate_uniqueness_of :name }
end
end

12
spec/models/badge_type.rb Normal file
View file

@ -0,0 +1,12 @@
require 'spec_helper'
require_dependency 'badge_type'
describe BadgeType do
it { should have_many :badges }
it { should validate_presence_of :name }
it { should validate_uniqueness_of :name }
it { should validate_presence_of :color_hexcode }
end

20
spec/models/user_badge.rb Normal file
View file

@ -0,0 +1,20 @@
require 'spec_helper'
require_dependency 'user_badge'
describe UserBadge do
it { should belong_to :badge }
it { should belong_to :user }
it { should belong_to :granted_by }
context 'validations' do
before(:each) { BadgeGranter.grant(Fabricate(:badge), Fabricate(:user)) }
it { should validate_presence_of(:badge_id) }
it { should validate_presence_of(:user_id) }
it { should validate_presence_of(:granted_at) }
it { should validate_presence_of(:granted_by) }
it { should validate_uniqueness_of(:badge_id).scoped_to(:user_id) }
end
end

View file

@ -10,6 +10,7 @@ describe User do
it { should have_many(:user_open_ids).dependent(:destroy) }
it { should have_many(:user_actions).dependent(:destroy) }
it { should have_many(:post_actions).dependent(:destroy) }
it { should have_many(:user_badges).dependent(:destroy) }
it { should have_many(:email_logs).dependent(:destroy) }
it { should have_many(:post_timings) }
it { should have_many(:topic_allowed_users).dependent(:destroy) }

View file

@ -0,0 +1,61 @@
require 'spec_helper'
describe BadgeGranter do
let(:badge) { Fabricate(:badge) }
let(:user) { Fabricate(:user) }
describe 'grant' do
it 'grants a badge' do
user_badge = BadgeGranter.grant(badge, user)
user_badge.should be_present
end
it 'sets granted_at' do
time = Time.zone.now
Timecop.freeze time
user_badge = BadgeGranter.grant(badge, user)
user_badge.granted_at.should eq(time)
Timecop.return
end
it 'sets granted_by if the option is present' do
admin = Fabricate(:admin)
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
user_badge = BadgeGranter.grant(badge, user)
user_badge.granted_by_id.should eq(Discourse.system_user.id)
end
it 'does not allow a regular user to grant badges' do
user_badge = BadgeGranter.grant(badge, user, granted_by: Fabricate(:user))
user_badge.should_not be_present
end
it 'increments grant_count on the badge' do
BadgeGranter.grant(badge, user)
badge.reload.grant_count.should eq(1)
end
end
describe 'revoke' do
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)
UserBadge.where(user: user, badge: badge).first.should_not be_present
badge.reload.grant_count.should eq(0)
end
end
end

View file

@ -0,0 +1,81 @@
module("Discourse.AdminBadgesController");
test("showDisplayName", function() {
var badge, controller;
badge = Discourse.Badge.create({name: "Test Badge"});
controller = testController(Discourse.AdminBadgesController, [badge]);
controller.send('selectBadge', badge);
ok(!controller.get('showDisplayName'), "does not show displayName when it is the same as the name");
this.stub(I18n, "t").returns("translated string");
badge = Discourse.Badge.create({name: "Test Badge"});
controller = testController(Discourse.AdminBadgesController, [badge]);
controller.send('selectBadge', badge);
ok(controller.get('showDisplayName'), "shows the displayName when it is different from the name");
});
test("canEditDescription", function() {
var badge, controller;
badge = Discourse.Badge.create({name: "Test Badge"});
controller = testController(Discourse.AdminBadgesController, [badge]);
controller.send('selectBadge', badge);
ok(controller.get('canEditDescription'), "allows editing description when a translation exists for the badge name");
this.stub(I18n, "t").returns("translated string");
badge = Discourse.Badge.create({name: "Test Badge"});
controller = testController(Discourse.AdminBadgesController, [badge]);
controller.send('selectBadge', badge);
ok(!controller.get('canEditDescription'), "shows the displayName when it is different from the name");
});
test("newBadge", function() {
var controller = testController(Discourse.AdminBadgesController, []);
controller.send('newBadge');
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() {
var badge = Discourse.Badge.create({name: "Test Badge"}),
controller = testController(Discourse.AdminBadgesController, [badge]);
controller.send('selectBadge', badge);
equal(controller.get('selectedItem'), badge, "the badge is selected");
});
test("save", function() {
var badge = Discourse.Badge.create({name: "Test Badge"}),
otherBadge = Discourse.Badge.create({name: "Other Badge"}),
controller = testController(Discourse.AdminBadgesController, [badge, otherBadge]);
controller.send('selectBadge', badge);
this.stub(badge, "save").returns(Ember.RSVP.resolve({}));
controller.send("save");
ok(badge.save.calledOnce, "called save on the badge");
});
test("destroy", function() {
var badge = Discourse.Badge.create({name: "Test Badge"}),
otherBadge = Discourse.Badge.create({name: "Other Badge"}),
controller = testController(Discourse.AdminBadgesController, [badge, otherBadge]);
this.stub(badge, 'destroy').returns(Ember.RSVP.resolve({}));
bootbox.confirm = function(text, yes, no, func) {
func(false);
};
controller.send('selectBadge', badge);
controller.send('destroy');
ok(!badge.destroy.calledOnce, "badge is not destroyed if they user clicks no");
bootbox.confirm = function(text, yes, no, func) {
func(true);
};
controller.send('selectBadge', badge);
controller.send('destroy');
ok(badge.destroy.calledOnce, "badge is destroyed if they user clicks yes");
});

View file

@ -0,0 +1,69 @@
module("Discourse.Badge");
test('newBadge', function() {
var badge1 = Discourse.Badge.create({name: "New Badge"}),
badge2 = Discourse.Badge.create({id: 1, name: "Old Badge"});
ok(badge1.get('newBadge'), "badges without ids are new");
ok(!badge2.get('newBadge'), "badges with ids are not new");
});
test('displayName', function() {
var badge1 = Discourse.Badge.create({id: 1, name: "Test Badge 1"});
equal(badge1.get('displayName'), "Test Badge 1", "falls back to the original name in the absence of a translation");
this.stub(I18n, "t").returnsArg(0);
var badge2 = Discourse.Badge.create({id: 2, name: "Test Badge 2"});
equal(badge2.get('displayName'), "badges.test_badge_2.name", "uses translation when available");
});
test('translatedDescription', function() {
var badge1 = Discourse.Badge.create({id: 1, name: "Test Badge 1"});
equal(badge1.get('translatedDescription'), null, "returns null 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('translatedDescription'), "description translation", "users translated description");
});
test('createFromJson array', function() {
var badgesJson = {"badge_types":[{"id":6,"name":"Silver 1","color_hexcode":"#c0c0c0"}],"badges":[{"id":1126,"name":"Badge 1","description":null,"badge_type_id":6}]};
var badges = Discourse.Badge.createFromJson(badgesJson);
ok(Array.isArray(badges), "returns an array");
equal(badges[0].get('name'), "Badge 1", "badge details are set");
equal(badges[0].get('badge_type.name'), "Silver 1", "badge_type reference is set");
});
test('createFromJson single', function() {
var badgeJson = {"badge_types":[{"id":6,"name":"Silver 1","color_hexcode":"#c0c0c0"}],"badge":{"id":1126,"name":"Badge 1","description":null,"badge_type_id":6}};
var badge = Discourse.Badge.createFromJson(badgeJson);
ok(!Array.isArray(badge), "does not returns an array");
});
test('updateFromJson', function() {
var badgeJson = {"badge_types":[{"id":6,"name":"Silver 1","color_hexcode":"#c0c0c0"}],"badge":{"id":1126,"name":"Badge 1","description":null,"badge_type_id":6}};
var badge = Discourse.Badge.create({name: "Badge 1"});
badge.updateFromJson(badgeJson);
equal(badge.get('id'), 1126, "id is set");
equal(badge.get('badge_type.name'), "Silver 1", "badge_type reference is set");
});
test('save', function() {
this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve({}));
var badge = Discourse.Badge.create({name: "New Badge", description: "This is a new badge.", badge_type_id: 1});
badge.save();
ok(Discourse.ajax.calledOnce, "saved badge");
});
test('destroy', function() {
this.stub(Discourse, 'ajax');
var badge = Discourse.Badge.create({name: "New Badge", description: "This is a new badge.", badge_type_id: 1});
badge.destroy();
ok(!Discourse.ajax.calledOnce, "no AJAX call for a new badge");
badge.set('id', 3);
badge.destroy();
ok(Discourse.ajax.calledOnce, "AJAX call was made");
});

View file

@ -0,0 +1,19 @@
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 userBadge = Discourse.UserBadge.createFromJson(json);
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");
equal(userBadge.get('granted_by.username'), "anne3", "granted_by reference is set");
});
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);
ok(Array.isArray(userBadges), "returns an array");
equal(userBadges[0].get('granted_by'), null, "granted_by reference is not set when null");
});