diff --git a/app/assets/javascripts/admin/controllers/admin_user_controller.js b/app/assets/javascripts/admin/controllers/admin_user_controller.js index c90516fc4fd..c0674fd6d04 100644 --- a/app/assets/javascripts/admin/controllers/admin_user_controller.js +++ b/app/assets/javascripts/admin/controllers/admin_user_controller.js @@ -24,6 +24,8 @@ Discourse.AdminUserIndexController = Discourse.ObjectController.extend({ return Discourse.SiteSettings.must_approve_users; }.property(), + primaryGroupDirty: Discourse.computed.propertyNotEqual('originalPrimaryGroupId', 'primary_group_id'), + actions: { toggleTitleEdit: function() { this.toggleProperty('editingTitle'); @@ -44,6 +46,22 @@ Discourse.AdminUserIndexController = Discourse.ObjectController.extend({ this.get('model').generateApiKey(); }, + savePrimaryGroup: function() { + var self = this; + Discourse.ajax("/admin/users/" + this.get('id') + "/primary_group", { + type: 'PUT', + data: {primary_group_id: this.get('primary_group_id')} + }).then(function () { + self.set('originalPrimaryGroupId', self.get('primary_group_id')); + }).catch(function() { + bootbox.alert(I18n.t('generic_error')); + }); + }, + + resetPrimaryGroup: function() { + this.set('primary_group_id', this.get('originalPrimaryGroupId')); + }, + regenerateApiKey: function() { var self = this; bootbox.confirm(I18n.t("admin.api.confirm_regen"), I18n.t("no_value"), I18n.t("yes_value"), function(result) { diff --git a/app/assets/javascripts/admin/routes/admin_user_route.js b/app/assets/javascripts/admin/routes/admin_user_route.js index 4a2086c7db4..f3e74174c03 100644 --- a/app/assets/javascripts/admin/routes/admin_user_route.js +++ b/app/assets/javascripts/admin/routes/admin_user_route.js @@ -23,10 +23,16 @@ Discourse.AdminUserRoute = Discourse.Route.extend({ afterModel: function(adminUser) { var controller = this.controllerFor('adminUser'); - adminUser.loadDetails().then(function () { + return adminUser.loadDetails().then(function () { adminUser.setOriginalTrustLevel(); controller.set('model', adminUser); - window.scrollTo(0, 0); + }); + }, + + setupController: function(controller, model) { + controller.setProperties({ + originalPrimaryGroupId: model.get('primary_group_id'), + model: model }); }, diff --git a/app/assets/javascripts/admin/templates/user_index.js.handlebars b/app/assets/javascripts/admin/templates/user_index.js.handlebars index a764d4cf322..37383757ec9 100644 --- a/app/assets/javascripts/admin/templates/user_index.js.handlebars +++ b/app/assets/javascripts/admin/templates/user_index.js.handlebars @@ -46,6 +46,25 @@ +
+
{{i18n admin.groups.primary}}
+
+ {{#if custom_groups}} + {{combobox content=custom_groups value=primary_group_id nameProperty="name" none="admin.groups.no_primary"}} + {{else}} + — + {{/if}} +
+
+ {{#if primaryGroupDirty}} +
+ + +
+ {{/if}} +
+
+
{{i18n user.ip_address.title}}
{{ip_address}}
@@ -317,7 +336,7 @@

- diff --git a/app/assets/javascripts/admin/views/admin_user_view.js b/app/assets/javascripts/admin/views/admin_user_view.js new file mode 100644 index 00000000000..0d430af4f9e --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_user_view.js @@ -0,0 +1,10 @@ +/** + The view class for an Admin User + + @class AdminUserView + @extends Discourse.View + @namespace Discourse + @module Discourse +**/ +Discourse.AdminUserView = Discourse.View.extend(Discourse.ScrollTop); + diff --git a/app/assets/javascripts/discourse/components/post_gap_component.js b/app/assets/javascripts/discourse/components/post_gap_component.js index dfffe9a8217..432619d785b 100644 --- a/app/assets/javascripts/discourse/components/post_gap_component.js +++ b/app/assets/javascripts/discourse/components/post_gap_component.js @@ -25,7 +25,10 @@ Discourse.PostGapComponent = Ember.Component.extend({ if (this.get('loading')) { buffer.push(I18n.t('loading')); } else { - buffer.push(I18n.t('post.gap', {count: this.get('gap.length')})); + var gapLength = this.get('gap.length'); + if (gapLength) { + buffer.push(I18n.t('post.gap', {count: gapLength})); + } } }, diff --git a/app/assets/javascripts/discourse/templates/post.js.handlebars b/app/assets/javascripts/discourse/templates/post.js.handlebars index ffc1975a3a0..dd44c335a84 100644 --- a/app/assets/javascripts/discourse/templates/post.js.handlebars +++ b/app/assets/javascripts/discourse/templates/post.js.handlebars @@ -29,6 +29,7 @@ {{/if}} {{#if user_title}}
{{user_title}}
{{/if}} + {{#if primary_group_name}}
{{unbound primary_group_name}}
{{/if}}
{{else}}
diff --git a/app/assets/javascripts/discourse/views/post_view.js b/app/assets/javascripts/discourse/views/post_view.js index 3181db43cd0..dd3d70db3a6 100644 --- a/app/assets/javascripts/discourse/views/post_view.js +++ b/app/assets/javascripts/discourse/views/post_view.js @@ -12,13 +12,21 @@ Discourse.PostView = Discourse.GroupedView.extend(Ember.Evented, { classNameBindings: ['postTypeClass', 'selected', 'post.hidden:post-hidden', - 'post.deleted'], + 'post.deleted', + 'groupNameClass'], postBinding: 'content', postTypeClass: function() { return this.get('post.post_type') === Discourse.Site.currentProp('post_types.moderator_action') ? 'moderator' : 'regular'; }.property('post.post_type'), + groupNameClass: function() { + var primaryGroupName = this.get('post.primary_group_name'); + if (primaryGroupName) { + return "group-" + primaryGroupName; + } + }.property('post.primary_group_name'), + // If the cooked content changed, add the quote controls cookedChanged: function() { var postView = this; diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index 01684918b00..989efdc0d63 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -461,17 +461,26 @@ iframe { width: 45px; height: 45px; } - - .contents { + + .contents { text-align: center; - a { - display: block; + a { + display: block; margin: 0 auto; width: 45px; } + a.user-group { + margin: 4px 0 0 0; + padding: 0px; + color: $primary_light; + font-size: 80%; + width: 100%; + line-height: 13px; + } + h3 a { display: inline; width: auto; @@ -614,6 +623,7 @@ position: relative; } } + .user-title { margin-top: 8px; color: $primary_light; diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index e4ccdfdf970..70dec9c7ccd 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -442,6 +442,7 @@ iframe { float: left; } + .user-title { color: #aaa; padding-top: 2px; diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 27aec1e007d..b7abad65b06 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -17,6 +17,7 @@ class Admin::UsersController < Admin::AdminController :block, :unblock, :trust_level, + :primary_group, :generate_api_key, :revoke_api_key] @@ -94,6 +95,13 @@ class Admin::UsersController < Admin::AdminController render_serialized(@user, AdminUserSerializer) end + def primary_group + guardian.ensure_can_change_primary_group!(@user) + @user.primary_group_id = params[:primary_group_id] + @user.save! + render nothing: true + end + def trust_level guardian.ensure_can_change_trust_level!(@user) logger = StaffActionLogger.new(current_user) diff --git a/app/models/group.rb b/app/models/group.rb index ef2c3060c0f..d2f6cfb0d0e 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -220,6 +220,7 @@ class Group < ActiveRecord::Base if @deletions @deletions.each do |gu| gu.destroy + User.update_all 'primary_group_id = NULL', ['id = ? AND primary_group_id = ?', gu.user_id, gu.group_id] end end @deletions = nil diff --git a/app/serializers/admin_detailed_user_serializer.rb b/app/serializers/admin_detailed_user_serializer.rb index 975c92ea6f7..685c34c1c45 100644 --- a/app/serializers/admin_detailed_user_serializer.rb +++ b/app/serializers/admin_detailed_user_serializer.rb @@ -14,12 +14,14 @@ class AdminDetailedUserSerializer < AdminUserSerializer :private_topics_count, :can_delete_all_posts, :can_be_deleted, - :suspend_reason + :suspend_reason, + :primary_group_id has_one :approved_by, serializer: BasicUserSerializer, embed: :objects has_one :api_key, serializer: ApiKeySerializer, embed: :objects has_one :suspended_by, serializer: BasicUserSerializer, embed: :objects has_one :leader_requirements, serializer: LeaderRequirementsSerializer, embed: :objects + has_many :custom_groups, embed: :object, serializer: BasicGroupSerializer def can_revoke_admin scope.can_revoke_admin?(object) diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index 5f6253afaf9..9a334d1b66b 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -23,6 +23,7 @@ class PostSerializer < BasicPostSerializer :topic_slug, :topic_id, :display_username, + :primary_group_name, :version, :can_edit, :can_delete, @@ -75,6 +76,11 @@ class PostSerializer < BasicPostSerializer object.user.try(:name) end + def primary_group_name + return nil unless object.user + return @topic_view.primary_group_names[object.user.primary_group_id] if object.user.primary_group_id + end + def link_counts return @single_post_link_counts if @single_post_link_counts.present? diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a2261108a13..3b818c733d8 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1267,6 +1267,8 @@ en: other: "spam x{{count}}" groups: + primary: "Primary Group" + no_primary: "(no primary group)" title: "Groups" edit: "Edit Groups" selector_placeholder: "add users" diff --git a/config/routes.rb b/config/routes.rb index e19f563f4c2..4235cfbb32f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -58,6 +58,7 @@ Discourse::Application.routes.draw do put "block" put "unblock" put "trust_level" + put "primary_group" get "leader_requirements" end diff --git a/db/migrate/20140210194146_add_primary_group_id_to_users.rb b/db/migrate/20140210194146_add_primary_group_id_to_users.rb new file mode 100644 index 00000000000..84d328ff2c5 --- /dev/null +++ b/db/migrate/20140210194146_add_primary_group_id_to_users.rb @@ -0,0 +1,5 @@ +class AddPrimaryGroupIdToUsers < ActiveRecord::Migration + def change + add_column :users, :primary_group_id, :integer, null: true + end +end diff --git a/lib/guardian.rb b/lib/guardian.rb index 4ebe2804bf1..8867fd7232d 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -133,6 +133,10 @@ class Guardian user && is_staff? end + def can_change_primary_group?(user) + user && is_staff? + end + def can_change_trust_level?(user) user && is_staff? end diff --git a/lib/topic_view.rb b/lib/topic_view.rb index cfccb8c6649..1cbec56ab5b 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -111,6 +111,22 @@ class TopicView filter_posts_paged(opts[:page].to_i) end + def primary_group_names + return @group_names if @group_names + + primary_group_ids = Set.new + @posts.each do |p| + primary_group_ids << p.user.primary_group_id if p.user.try(:primary_group_id) + end + + result = {} + unless primary_group_ids.empty? + Group.where(id: primary_group_ids.to_a).pluck(:id, :name).each do |g| + result[g[0]] = g[1] + end + end + result + end # Find the sort order for a post in the topic def sort_order_for_post_number(post_number) diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 358aefbe2ba..cf9aa4423eb 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -140,6 +140,29 @@ describe Admin::UsersController do end end + context '.primary_group' do + before do + @another_user = Fabricate(:coding_horror) + end + + it "raises an error when the user doesn't have permission" do + Guardian.any_instance.expects(:can_change_primary_group?).with(@another_user).returns(false) + xhr :put, :primary_group, user_id: @another_user.id + response.should be_forbidden + end + + it "returns a 404 if the user doesn't exist" do + xhr :put, :primary_group, user_id: 123123 + response.should be_forbidden + end + + it "chagnes the user's trust level" do + xhr :put, :primary_group, user_id: @another_user.id, primary_group_id: 2 + @another_user.reload + @another_user.primary_group_id.should == 2 + end + end + context '.trust_level' do before do @another_user = Fabricate(:coding_horror) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 7d09fdbc0bb..8a6b2e4251e 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1050,4 +1050,35 @@ describe User do end end + describe "primary_group_id" do + let!(:user) { Fabricate(:user) } + + it "has no primary_group_id by default" do + user.primary_group_id.should be_nil + end + + context "when the user has a group" do + let!(:group) { Fabricate(:group) } + + before do + group.usernames = user.username + group.save + user.primary_group_id = group.id + user.save + user.reload + end + + it "should allow us to use it as a primary group" do + user.primary_group_id.should == group.id + + # If we remove the user from the group + group.usernames = "" + group.save + + # It should unset it from the primary_group_id + user.reload + user.primary_group_id.should be_nil + end + end + end end