From 7dcf2a2c4f6c42768fb046cd54ea5b0d7b9360a6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Wed, 16 Jul 2014 21:04:55 +0200
Subject: [PATCH] FEATURE: show the user's flagged/deleted posts

---
 .../discourse/models/{post.js => _post.js}    |  0
 .../discourse/models/admin_post.js            | 48 ++++++++++++
 .../javascripts/discourse/models/user.js      | 10 +++
 .../discourse/models/user_posts_stream.js     | 53 +++++++++++++
 .../discourse/models/user_stream.js           | 10 ++-
 .../discourse/routes/application_routes.js    |  2 +
 .../routes/user_admin_posts_routes.js         | 23 ++++++
 .../templates/user/posts.js.handlebars        | 32 ++++++++
 .../templates/user/stream.js.handlebars       |  3 +
 .../templates/user/user.js.handlebars         | 11 ++-
 .../discourse/views/user/user_posts_view.js   | 27 +++++++
 app/assets/stylesheets/desktop/user.scss      |  3 +
 app/controllers/posts_controller.rb           | 40 ++++++++++
 app/models/post.rb                            |  3 +
 app/serializers/admin_post_serializer.rb      | 76 +++++++++++++++++++
 config/routes.rb                              |  4 +
 lib/guardian/post_guardian.rb                 |  8 ++
 spec/controllers/posts_controller_spec.rb     | 47 ++++++++++++
 18 files changed, 395 insertions(+), 5 deletions(-)
 rename app/assets/javascripts/discourse/models/{post.js => _post.js} (100%)
 create mode 100644 app/assets/javascripts/discourse/models/admin_post.js
 create mode 100644 app/assets/javascripts/discourse/models/user_posts_stream.js
 create mode 100644 app/assets/javascripts/discourse/routes/user_admin_posts_routes.js
 create mode 100644 app/assets/javascripts/discourse/templates/user/posts.js.handlebars
 create mode 100644 app/assets/javascripts/discourse/views/user/user_posts_view.js
 create mode 100644 app/serializers/admin_post_serializer.rb

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}}
+  <div {{bind-attr class=":item hidden deleted moderator_action"}}>
+    <div class="clearfix info">
+      <a href="{{unbound usernameUrl}}" class="avatar-link">
+        <div class="avatar-wrapper">
+          {{avatar this imageSize="large" extraClasses="actor" ignoreTitle="true"}}
+        </div>
+      </a>
+      <span class="time">
+        {{date path="created_at" leaveAgo="true"}}
+      </span>
+      <span class="title">
+        <a href="{{unbound url}}">{{unbound topic_title}}</a>
+        {{category-link category}}
+      </span>
+      <span class="type">
+        {{descriptionHtml}}
+      </span>
+      {{#if deleted}}
+        <span class="time">
+          {{i18n post.deleted_by}} {{avatar deleted_by imageSize="tiny" extraClasses="actor" ignoreTitle="true"}} {{date path="deleted_at" leaveAgo="true"}}
+        </span>
+      {{/if}}
+    </div>
+    <p class="excerpt">
+      {{{excerpt}}}
+    </p>
+  </div>
+{{/each}}
+{{#if loading}}
+  <div class='spinner'>{{i18n loading}}</div>
+{{/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}}
   </div>
 {{/groupedEach}}
+{{#if loading}}
+  <div class='spinner'>{{i18n loading}}</div>
+{{/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 @@
                 <div><span class="pill helpful-flags">{{number_of_flags_given}}</span>&nbsp;{{i18n user.staff_counters.flags_given}}</div>
               {{/if}}
               {{#if number_of_flagged_posts}}
-                <div><span class="pill flagged-posts">{{number_of_flagged_posts}}</span>&nbsp;{{i18n user.staff_counters.flagged_posts}}</div>
+                <div>
+                  {{#link-to 'user.flaggedPosts' this}}
+                    <span class="pill flagged-posts">{{number_of_flagged_posts}}</span>&nbsp;{{i18n user.staff_counters.flagged_posts}}</div>
+                  {{/link-to}}
               {{/if}}
               {{#if number_of_deleted_posts}}
-                <div><span class="pill deleted-posts">{{number_of_deleted_posts}}</span>&nbsp;{{i18n user.staff_counters.deleted_posts}}</div>
+                <div>
+                  {{#link-to 'user.deletedPosts' this}}
+                    <span class="pill deleted-posts">{{number_of_deleted_posts}}</span>&nbsp;{{i18n user.staff_counters.deleted_posts}}
+                  {{/link-to}}
+                </div>
               {{/if}}
               {{#if number_of_suspensions}}
                 <div><span class="pill suspensions">{{number_of_suspensions}}</span>&nbsp;{{i18n user.staff_counters.suspensions}}</div>
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