FEATURE: show the user's flagged/deleted posts

This commit is contained in:
Régis Hanol 2014-07-16 21:04:55 +02:00
parent 71c67c43a1
commit 7dcf2a2c4f
18 changed files with 395 additions and 5 deletions

View File

@ -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")
});

View File

@ -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?

View File

@ -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);
});
}
});

View File

@ -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();
},

View File

@ -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');

View File

@ -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");

View File

@ -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}}

View File

@ -27,3 +27,6 @@
{{/groupedEach}}
</div>
{{/groupedEach}}
{{#if loading}}
<div class='spinner'>{{i18n loading}}</div>
{{/if}}

View File

@ -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>

View File

@ -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 () { });
}
}
});

View File

@ -364,6 +364,9 @@
> div {
margin-bottom: 10px;
}
a.active {
font-weight: bold;
}
}
.pill {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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