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