diff --git a/app/assets/javascripts/discourse/models/post.js b/app/assets/javascripts/discourse/models/_post.js similarity index 100% rename from app/assets/javascripts/discourse/models/post.js rename to app/assets/javascripts/discourse/models/_post.js diff --git a/app/assets/javascripts/discourse/models/admin_post.js b/app/assets/javascripts/discourse/models/admin_post.js new file mode 100644 index 00000000000..14ef3444057 --- /dev/null +++ b/app/assets/javascripts/discourse/models/admin_post.js @@ -0,0 +1,48 @@ +/** + A data model for flagged/deleted posts. + + @class AdminPost + @extends Discourse.Post + @namespace Discourse + @module Discourse +**/ +Discourse.AdminPost = Discourse.Post.extend({ + + _attachCategory: function () { + var categoryId = this.get("category_id"); + if (categoryId) { + this.set("category", Discourse.Category.findById(categoryId)); + } + }.on("init"), + + presentName: Em.computed.any('name', 'username'), + + sameUser: function() { + return this.get("username") === Discourse.User.currentProp("username"); + }.property("username"), + + descriptionKey: function () { + if (this.get("reply_to_post_number")) { + return this.get("sameUser") ? "you_replied_to_post" : "user_replied_to_post"; + } else { + return this.get("sameUser") ? "you_replied_to_topic" : "user_replied_to_topic"; + } + }.property("reply_to_post_number", "sameUser"), + + descriptionHtml: function () { + var descriptionKey = this.get("descriptionKey"); + if (!descriptionKey) { return; } + + var description = I18n.t("user_action." + descriptionKey, { + userUrl: this.get("usernameUrl"), + user: Handlebars.Utils.escapeExpression(this.get("presentName")), + postUrl: this.get("url"), + post_number: "#" + this.get("reply_to_post_number"), + topicUrl: this.get("url"), + }); + + return new Handlebars.SafeString(description); + + }.property("descriptionKey") + +}); diff --git a/app/assets/javascripts/discourse/models/user.js b/app/assets/javascripts/discourse/models/user.js index 3719b78dc28..99010adaf0e 100644 --- a/app/assets/javascripts/discourse/models/user.js +++ b/app/assets/javascripts/discourse/models/user.js @@ -22,6 +22,16 @@ Discourse.User = Discourse.Model.extend({ return Discourse.UserStream.create({ user: this }); }.property(), + /** + The user's posts stream + + @property postsStream + @type {Discourse.UserPostsStream} + **/ + postsStream: function() { + return Discourse.UserPostsStream.create({ user: this }); + }.property(), + /** Is this user a member of staff? diff --git a/app/assets/javascripts/discourse/models/user_posts_stream.js b/app/assets/javascripts/discourse/models/user_posts_stream.js new file mode 100644 index 00000000000..7447cbe63af --- /dev/null +++ b/app/assets/javascripts/discourse/models/user_posts_stream.js @@ -0,0 +1,53 @@ +/** + Represents a user's stream + + @class UserPostsStream + @extends Discourse.Model + @namespace Discourse + @module Discourse +**/ +Discourse.UserPostsStream = Discourse.Model.extend({ + loaded: false, + + _initialize: function () { + this.setProperties({ + itemsLoaded: 0, + content: [] + }); + }.on("init"), + + url: Discourse.computed.url("user.username_lower", "filter", "itemsLoaded", "/posts/%@/%@?offset=%@"), + + filterBy: function (filter) { + if (this.get("loaded") && this.get("filter") === filter) { return Ember.RSVP.resolve(); } + + this.setProperties({ + filter: filter, + itemsLoaded: 0, + content: [] + }); + + return this.findItems(); + }, + + findItems: function () { + var self = this; + if (this.get("loading")) { return Ember.RSVP.reject(); } + + this.set("loading", true); + + return Discourse.ajax(this.get("url"), { cache: false }).then(function (result) { + if (result) { + var posts = result.map(function (post) { return Discourse.AdminPost.create(post); }); + self.get("content").pushObjects(posts); + self.setProperties({ + loaded: true, + itemsLoaded: self.get("itemsLoaded") + posts.length + }); + } + }).finally(function () { + self.set("loading", false); + }); + } + +}); diff --git a/app/assets/javascripts/discourse/models/user_stream.js b/app/assets/javascripts/discourse/models/user_stream.js index 27fcc13a39e..67b94e38720 100644 --- a/app/assets/javascripts/discourse/models/user_stream.js +++ b/app/assets/javascripts/discourse/models/user_stream.js @@ -9,9 +9,12 @@ Discourse.UserStream = Discourse.Model.extend({ loaded: false, - init: function() { - this.setProperties({ itemsLoaded: 0, content: [] }); - }, + _initialize: function() { + this.setProperties({ + itemsLoaded: 0, + content: [] + }); + }.on("init"), filterParam: function() { var filter = this.get('filter'); @@ -33,6 +36,7 @@ Discourse.UserStream = Discourse.Model.extend({ itemsLoaded: 0, content: [] }); + return this.findItems(); }, diff --git a/app/assets/javascripts/discourse/routes/application_routes.js b/app/assets/javascripts/discourse/routes/application_routes.js index 05eb7692786..4cbbe796391 100644 --- a/app/assets/javascripts/discourse/routes/application_routes.js +++ b/app/assets/javascripts/discourse/routes/application_routes.js @@ -73,6 +73,8 @@ Discourse.Route.buildRoutes(function() { }); this.route('badges'); + this.route('flaggedPosts', { path: '/flagged-posts' }); + this.route('deletedPosts', { path: '/deleted-posts' }); this.resource('userPrivateMessages', { path: '/private-messages' }, function() { this.route('mine'); diff --git a/app/assets/javascripts/discourse/routes/user_admin_posts_routes.js b/app/assets/javascripts/discourse/routes/user_admin_posts_routes.js new file mode 100644 index 00000000000..1440f0ea7f8 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/user_admin_posts_routes.js @@ -0,0 +1,23 @@ +function createAdminPostRoute (filter) { + return Discourse.Route.extend({ + model: function () { + return this.modelFor("user").get("postsStream"); + }, + + afterModel: function () { + return this.modelFor("user").get("postsStream").filterBy(filter); + }, + + setupController: function (controller, model) { + controller.set("model", model); + this.controllerFor("user").set("indexStream", true); + }, + + renderTemplate: function() { + this.render("user/posts", { into: "user", outlet: "userOutlet" }); + } + }); +} + +Discourse.UserDeletedPostsRoute = createAdminPostRoute("deleted"); +Discourse.UserFlaggedPostsRoute = createAdminPostRoute("flagged"); diff --git a/app/assets/javascripts/discourse/templates/user/posts.js.handlebars b/app/assets/javascripts/discourse/templates/user/posts.js.handlebars new file mode 100644 index 00000000000..9c1522d30e9 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/user/posts.js.handlebars @@ -0,0 +1,32 @@ +{{#each model.content}} + +{{/each}} +{{#if loading}} +
{{i18n loading}}
+{{/if}} diff --git a/app/assets/javascripts/discourse/templates/user/stream.js.handlebars b/app/assets/javascripts/discourse/templates/user/stream.js.handlebars index 7a3b328c157..e5e83e19837 100644 --- a/app/assets/javascripts/discourse/templates/user/stream.js.handlebars +++ b/app/assets/javascripts/discourse/templates/user/stream.js.handlebars @@ -27,3 +27,6 @@ {{/groupedEach}} {{/groupedEach}} +{{#if loading}} +
{{i18n loading}}
+{{/if}} diff --git a/app/assets/javascripts/discourse/templates/user/user.js.handlebars b/app/assets/javascripts/discourse/templates/user/user.js.handlebars index 63713e09370..639562c566a 100644 --- a/app/assets/javascripts/discourse/templates/user/user.js.handlebars +++ b/app/assets/javascripts/discourse/templates/user/user.js.handlebars @@ -69,10 +69,17 @@
{{number_of_flags_given}} {{i18n user.staff_counters.flags_given}}
{{/if}} {{#if number_of_flagged_posts}} -
{{number_of_flagged_posts}} {{i18n user.staff_counters.flagged_posts}}
+
+ {{#link-to 'user.flaggedPosts' this}} + {{number_of_flagged_posts}} {{i18n user.staff_counters.flagged_posts}}
+ {{/link-to}} {{/if}} {{#if number_of_deleted_posts}} -
{{number_of_deleted_posts}} {{i18n user.staff_counters.deleted_posts}}
+
+ {{#link-to 'user.deletedPosts' this}} + {{number_of_deleted_posts}} {{i18n user.staff_counters.deleted_posts}} + {{/link-to}} +
{{/if}} {{#if number_of_suspensions}}
{{number_of_suspensions}} {{i18n user.staff_counters.suspensions}}
diff --git a/app/assets/javascripts/discourse/views/user/user_posts_view.js b/app/assets/javascripts/discourse/views/user/user_posts_view.js new file mode 100644 index 00000000000..7d862a1032c --- /dev/null +++ b/app/assets/javascripts/discourse/views/user/user_posts_view.js @@ -0,0 +1,27 @@ +/** + This view handles rendering of a user's posts + + @class UserPostsView + @extends Discourse.View + @namespace Discourse + @uses Discourse.LoadMore + @module Discourse +**/ +Discourse.UserPostsView = Discourse.View.extend(Discourse.LoadMore, { + loading: false, + eyelineSelector: ".user-stream .item", + classNames: ["user-stream"], + + actions: { + loadMore: function() { + var self = this; + if (this.get("loading")) { return; } + + var postsStream = this.get("controller.model"); + postsStream.findItems().then(function () { + self.set("loading", false); + self.get("eyeline").flushRest(); + }).catch(function () { }); + } + } +}); diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index c079b6ae31c..2fe9c1ef742 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -364,6 +364,9 @@ > div { margin-bottom: 10px; } + a.active { + font-weight: bold; + } } .pill { diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index a8e7cb3f9f0..674ebc4cfca 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -244,6 +244,37 @@ class PostsController < ApplicationController render nothing: true end + def flagged_posts + params.permit(:offset, :limit) + guardian.ensure_can_see_flagged_posts! + + user = fetch_user_from_params + offset = [params[:offset].to_i, 0].max + limit = [(params[:limit] || 60).to_i, 100].min + + posts = user_posts(user.id, offset, limit) + .where(id: PostAction.with_deleted + .where(post_action_type_id: PostActionType.notify_flag_type_ids) + .select(:post_id)) + + render_serialized(posts, AdminPostSerializer) + end + + def deleted_posts + params.permit(:offset, :limit) + guardian.ensure_can_see_deleted_posts! + + user = fetch_user_from_params + offset = [params[:offset].to_i, 0].max + limit = [(params[:limit] || 60).to_i, 100].min + + posts = user_posts(user.id, offset, limit) + .where(user_deleted: false) + .where.not(deleted_by_id: user.id) + + render_serialized(posts, AdminPostSerializer) + end + protected def find_post_revision_from_params @@ -272,6 +303,15 @@ class PostsController < ApplicationController private + def user_posts(user_id, offset=0, limit=60) + Post.includes(:user, :topic, :deleted_by, :user_actions) + .with_deleted + .where(user_id: user_id) + .order(created_at: :desc) + .offset(offset) + .limit(limit) + end + def params_key(params) "post##" << Digest::SHA1.hexdigest(params .to_a diff --git a/app/models/post.rb b/app/models/post.rb index 54989c3c0bf..6ad40bec33f 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -23,6 +23,7 @@ class Post < ActiveRecord::Base belongs_to :user belongs_to :topic, counter_cache: :posts_count + belongs_to :reply_to_user, class_name: "User" has_many :post_replies @@ -40,6 +41,8 @@ class Post < ActiveRecord::Base has_many :post_revisions has_many :revisions, foreign_key: :post_id, class_name: 'PostRevision' + has_many :user_actions, foreign_key: :target_post_id + validates_with ::Validators::PostValidator # We can pass several creating options to a post via attributes diff --git a/app/serializers/admin_post_serializer.rb b/app/serializers/admin_post_serializer.rb new file mode 100644 index 00000000000..38a31992dcc --- /dev/null +++ b/app/serializers/admin_post_serializer.rb @@ -0,0 +1,76 @@ +class AdminPostSerializer < ApplicationSerializer + + attributes :id, + :created_at, + :post_number, + :name, :username, :avatar_template, :uploaded_avatar_id, + :topic_id, :topic_slug, :topic_title, + :category_id, + :excerpt, + :hidden, + :moderator_action, + :deleted_at, :deleted_by, + :reply_to_post_number, + :action_type + + def name + object.user.name + end + + def include_name? + SiteSetting.enable_names? + end + + def username + object.user.username + end + + def avatar_template + object.user.avatar_template + end + + def uploaded_avatar_id + object.user.uploaded_avatar_id + end + + def topic_slug + topic.slug + end + + def topic_title + topic.title + end + + def category_id + topic.category_id + end + + def moderator_action + object.post_type == Post.types[:moderator_action] + end + + def deleted_by + BasicUserSerializer.new(object.deleted_by, root: false).as_json + end + + def include_deleted_by? + object.trashed? + end + + def action_type + object.user_actions.select { |ua| ua.user_id = object.user_id } + .select { |ua| [UserAction::REPLY, UserAction::RESPONSE].include? ua.action_type } + .first.try(:action_type) + end + + private + + # we need this to handle deleted topics which aren't loaded via the .includes(:topic) + # because Rails 4 "unscoped" support is bugged (cf. https://github.com/rails/rails/issues/13775) + def topic + return @topic if @topic + @topic = object.topic || Topic.with_deleted.find(object.topic_id) + @topic + end + +end diff --git a/config/routes.rb b/config/routes.rb index eae6e189e32..cc8943ceb3d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -218,6 +218,8 @@ Discourse::Application.routes.draw do get "users/:username/badges" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT} delete "users/:username" => "users#destroy", constraints: {username: USERNAME_ROUTE_FORMAT} get "users/by-external/:external_id" => "users#show" + get "users/:username/flagged-posts" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT} + get "users/:username/deleted-posts" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT} post "user_avatar/:username/refresh_gravatar" => "user_avatars#refresh_gravatar" get "letter_avatar/:username/:size/:version.png" => "user_avatars#show_letter", @@ -231,6 +233,8 @@ Discourse::Application.routes.draw do get "posts/by_number/:topic_id/:post_number" => "posts#by_number" get "posts/:id/reply-history" => "posts#reply_history" + get "posts/:username/deleted" => "posts#deleted_posts", constraints: {username: USERNAME_ROUTE_FORMAT} + get "posts/:username/flagged" => "posts#flagged_posts", constraints: {username: USERNAME_ROUTE_FORMAT} resources :groups do get 'members' diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb index 09043c890bd..17dcaf83d8e 100644 --- a/lib/guardian/post_guardian.rb +++ b/lib/guardian/post_guardian.rb @@ -156,4 +156,12 @@ module PostGuardian def can_wiki? is_staff? || @user.has_trust_level?(:elder) end + + def can_see_flagged_posts? + is_staff? + end + + def can_see_deleted_posts? + is_staff? + end end diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb index 1a5c687cf9c..39e9ec9e546 100644 --- a/spec/controllers/posts_controller_spec.rb +++ b/spec/controllers/posts_controller_spec.rb @@ -606,4 +606,51 @@ describe PostsController do ::JSON.parse(response.body)['cooked'].should == "full content" end end + + describe "flagged posts" do + + include_examples "action requires login", :get, :flagged_posts, username: "system" + + describe "when logged in" do + before { log_in } + + it "raises an error if the user doesn't have permission to see the flagged posts" do + Guardian.any_instance.expects(:can_see_flagged_posts?).returns(false) + xhr :get, :flagged_posts, username: "system" + response.should be_forbidden + end + + it "can see the flagged posts when authorized" do + Guardian.any_instance.expects(:can_see_flagged_posts?).returns(true) + xhr :get, :flagged_posts, username: "system" + response.should be_success + end + + end + + end + + describe "deleted posts" do + + include_examples "action requires login", :get, :deleted_posts, username: "system" + + describe "when logged in" do + before { log_in } + + it "raises an error if the user doesn't have permission to see the deleted posts" do + Guardian.any_instance.expects(:can_see_deleted_posts?).returns(false) + xhr :get, :deleted_posts, username: "system" + response.should be_forbidden + end + + it "can see the deleted posts when authorized" do + Guardian.any_instance.expects(:can_see_deleted_posts?).returns(true) + xhr :get, :deleted_posts, username: "system" + response.should be_success + end + + end + + end + end