diff --git a/app/assets/javascripts/admin/controllers/admin_badge_controller.js b/app/assets/javascripts/admin/controllers/admin_badge_controller.js new file mode 100644 index 000000000..d45f280d1 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_badge_controller.js @@ -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') +}); diff --git a/app/assets/javascripts/admin/controllers/admin_badges_controller.js b/app/assets/javascripts/admin/controllers/admin_badges_controller.js new file mode 100644 index 000000000..705aad553 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_badges_controller.js @@ -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')); + }); + } + }); + } + + } + +}); diff --git a/app/assets/javascripts/admin/controllers/admin_user_controller.js b/app/assets/javascripts/admin/controllers/admin_user_controller.js index c0674fd6d..ec1465027 100644 --- a/app/assets/javascripts/admin/controllers/admin_user_controller.js +++ b/app/assets/javascripts/admin/controllers/admin_user_controller.js @@ -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: { diff --git a/app/assets/javascripts/admin/routes/admin_badges_route.js b/app/assets/javascripts/admin/routes/admin_badges_route.js new file mode 100644 index 000000000..1c606b557 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_badges_route.js @@ -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); + } + +}); diff --git a/app/assets/javascripts/admin/routes/admin_routes.js b/app/assets/javascripts/admin/routes/admin_routes.js index 3b847a36d..21b2b38b6 100644 --- a/app/assets/javascripts/admin/routes/admin_routes.js +++ b/app/assets/javascripts/admin/routes/admin_routes.js @@ -57,5 +57,7 @@ Discourse.Route.buildRoutes(function() { }); }); + this.route('badges'); + }); }); diff --git a/app/assets/javascripts/admin/templates/admin.js.handlebars b/app/assets/javascripts/admin/templates/admin.js.handlebars index f8044020f..43f18b5e2 100644 --- a/app/assets/javascripts/admin/templates/admin.js.handlebars +++ b/app/assets/javascripts/admin/templates/admin.js.handlebars @@ -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> diff --git a/app/assets/javascripts/admin/templates/badges.js.handlebars b/app/assets/javascripts/admin/templates/badges.js.handlebars new file mode 100644 index 000000000..ff39e7418 --- /dev/null +++ b/app/assets/javascripts/admin/templates/badges.js.handlebars @@ -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> diff --git a/app/assets/javascripts/admin/templates/user_index.js.handlebars b/app/assets/javascripts/admin/templates/user_index.js.handlebars index a99918ade..8af5cd2c5 100644 --- a/app/assets/javascripts/admin/templates/user_index.js.handlebars +++ b/app/assets/javascripts/admin/templates/user_index.js.handlebars @@ -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"}}> diff --git a/app/assets/javascripts/discourse/models/badge.js b/app/assets/javascripts/discourse/models/badge.js new file mode 100644 index 000000000..ee712f601 --- /dev/null +++ b/app/assets/javascripts/discourse/models/badge.js @@ -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); + }); + } +}); diff --git a/app/assets/javascripts/discourse/models/user_badge.js b/app/assets/javascripts/discourse/models/user_badge.js new file mode 100644 index 000000000..f0f6595c8 --- /dev/null +++ b/app/assets/javascripts/discourse/models/user_badge.js @@ -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; + } + } +}); diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 8b608e4d7..7a69efcd8 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -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 { diff --git a/app/controllers/admin/badges_controller.rb b/app/controllers/admin/badges_controller.rb new file mode 100644 index 000000000..1cfb1ef8e --- /dev/null +++ b/app/controllers/admin/badges_controller.rb @@ -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 diff --git a/app/controllers/user_badges_controller.rb b/app/controllers/user_badges_controller.rb new file mode 100644 index 000000000..3044d7c16 --- /dev/null +++ b/app/controllers/user_badges_controller.rb @@ -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 diff --git a/app/models/badge.rb b/app/models/badge.rb new file mode 100644 index 000000000..f5dfbdc10 --- /dev/null +++ b/app/models/badge.rb @@ -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 +# diff --git a/app/models/badge_type.rb b/app/models/badge_type.rb new file mode 100644 index 000000000..ede440185 --- /dev/null +++ b/app/models/badge_type.rb @@ -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 +# diff --git a/app/models/user.rb b/app/models/user.rb index d0ecc1b37..cd01e28b3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/models/user_badge.rb b/app/models/user_badge.rb new file mode 100644 index 000000000..e62c3e0db --- /dev/null +++ b/app/models/user_badge.rb @@ -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) +# diff --git a/app/serializers/badge_serializer.rb b/app/serializers/badge_serializer.rb new file mode 100644 index 000000000..05173cc30 --- /dev/null +++ b/app/serializers/badge_serializer.rb @@ -0,0 +1,5 @@ +class BadgeSerializer < ApplicationSerializer + attributes :id, :name, :description + + has_one :badge_type +end diff --git a/app/serializers/badge_type_serializer.rb b/app/serializers/badge_type_serializer.rb new file mode 100644 index 000000000..bd6ed272d --- /dev/null +++ b/app/serializers/badge_type_serializer.rb @@ -0,0 +1,3 @@ +class BadgeTypeSerializer < ApplicationSerializer + attributes :id, :name, :color_hexcode +end diff --git a/app/serializers/user_badge_serializer.rb b/app/serializers/user_badge_serializer.rb new file mode 100644 index 000000000..d91dfb916 --- /dev/null +++ b/app/serializers/user_badge_serializer.rb @@ -0,0 +1,6 @@ +class UserBadgeSerializer < ApplicationSerializer + attributes :id, :granted_at + + has_one :badge + has_one :granted_by, serializer: BasicUserSerializer, root: :users +end diff --git a/app/services/badge_granter.rb b/app/services/badge_granter.rb new file mode 100644 index 000000000..2fb74972d --- /dev/null +++ b/app/services/badge_granter.rb @@ -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 diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 5e29bbcf0..9828b5b27 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1666,6 +1666,18 @@ en: legal: "Legal" uncategorized: 'Uncategorized' + 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" @@ -1707,3 +1719,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. diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 5e68dce6e..5526a2b71 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -658,6 +658,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." @@ -1396,3 +1398,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 diff --git a/config/routes.rb b/config/routes.rb index c684da82f..40b9c6181 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/config/site_settings.yml b/config/site_settings.yml index 4758abd87..6eaff3599 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -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: diff --git a/db/fixtures/700_badge_types.rb b/db/fixtures/700_badge_types.rb new file mode 100644 index 000000000..475817e85 --- /dev/null +++ b/db/fixtures/700_badge_types.rb @@ -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 diff --git a/db/migrate/20140304200606_create_badge_types.rb b/db/migrate/20140304200606_create_badge_types.rb new file mode 100644 index 000000000..e580c3a1e --- /dev/null +++ b/db/migrate/20140304200606_create_badge_types.rb @@ -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 diff --git a/db/migrate/20140304201403_create_badges.rb b/db/migrate/20140304201403_create_badges.rb new file mode 100644 index 000000000..1fbc9b52a --- /dev/null +++ b/db/migrate/20140304201403_create_badges.rb @@ -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 diff --git a/db/migrate/20140305100909_create_user_badges.rb b/db/migrate/20140305100909_create_user_badges.rb new file mode 100644 index 000000000..52afead5a --- /dev/null +++ b/db/migrate/20140305100909_create_user_badges.rb @@ -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 diff --git a/lib/guardian.rb b/lib/guardian.rb index 65e07b293..dbe0526d8 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -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? diff --git a/spec/controllers/admin/badges_controller_spec.rb b/spec/controllers/admin/badges_controller_spec.rb new file mode 100644 index 000000000..5359c278f --- /dev/null +++ b/spec/controllers/admin/badges_controller_spec.rb @@ -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 diff --git a/spec/controllers/user_badges_controller_spec.rb b/spec/controllers/user_badges_controller_spec.rb new file mode 100644 index 000000000..4836df3ff --- /dev/null +++ b/spec/controllers/user_badges_controller_spec.rb @@ -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 diff --git a/spec/fabricators/badge_fabricator.rb b/spec/fabricators/badge_fabricator.rb new file mode 100644 index 000000000..7ba52d8b4 --- /dev/null +++ b/spec/fabricators/badge_fabricator.rb @@ -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 diff --git a/spec/models/badge.rb b/spec/models/badge.rb new file mode 100644 index 000000000..3309f9ce0 --- /dev/null +++ b/spec/models/badge.rb @@ -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 + diff --git a/spec/models/badge_type.rb b/spec/models/badge_type.rb new file mode 100644 index 000000000..bc716b857 --- /dev/null +++ b/spec/models/badge_type.rb @@ -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 diff --git a/spec/models/user_badge.rb b/spec/models/user_badge.rb new file mode 100644 index 000000000..383db76a1 --- /dev/null +++ b/spec/models/user_badge.rb @@ -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 diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 65554f4cd..b93370bfc 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -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) } diff --git a/spec/services/badge_granter_spec.rb b/spec/services/badge_granter_spec.rb new file mode 100644 index 000000000..1ffcde77f --- /dev/null +++ b/spec/services/badge_granter_spec.rb @@ -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 diff --git a/test/javascripts/admin/controllers/admin_badges_controller_test.js b/test/javascripts/admin/controllers/admin_badges_controller_test.js new file mode 100644 index 000000000..532c8cb36 --- /dev/null +++ b/test/javascripts/admin/controllers/admin_badges_controller_test.js @@ -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"); +}); diff --git a/test/javascripts/models/badge_test.js b/test/javascripts/models/badge_test.js new file mode 100644 index 000000000..b19f55e29 --- /dev/null +++ b/test/javascripts/models/badge_test.js @@ -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"); +}); diff --git a/test/javascripts/models/user_badge_test.js b/test/javascripts/models/user_badge_test.js new file mode 100644 index 000000000..306549ad6 --- /dev/null +++ b/test/javascripts/models/user_badge_test.js @@ -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"); +});