Users can see their pending posts

This commit is contained in:
Robin Ward 2015-04-21 14:36:46 -04:00
parent 26693c16ac
commit 5bf8c31af4
17 changed files with 225 additions and 101 deletions

View File

@ -19,10 +19,7 @@ export default ObjectController.extend(CanCheckEmails, {
linkWebsite: Em.computed.not('isBasic'), linkWebsite: Em.computed.not('isBasic'),
canSeePrivateMessages: function() { canSeePrivateMessages: Ember.computed.or('viewingSelf', 'currentUser.admin'),
return this.get('viewingSelf') || Discourse.User.currentProp('admin');
}.property('viewingSelf'),
canSeeNotificationHistory: Em.computed.alias('canSeePrivateMessages'), canSeeNotificationHistory: Em.computed.alias('canSeePrivateMessages'),
showBadges: function() { showBadges: function() {

View File

@ -1,12 +1,3 @@
/**
A data model representing actions users have taken
@class UserAction
@extends Discourse.Model
@namespace Discourse
@module Discourse
**/
var UserActionTypes = { var UserActionTypes = {
likes_given: 1, likes_given: 1,
likes_received: 2, likes_received: 2,
@ -18,7 +9,8 @@ var UserActionTypes = {
quotes: 9, quotes: 9,
edits: 11, edits: 11,
messages_sent: 12, messages_sent: 12,
messages_received: 13 messages_received: 13,
pending: 14
}, },
InvertedActionTypes = {}; InvertedActionTypes = {};

View File

@ -0,0 +1,5 @@
import UserActivityStreamRoute from "discourse/routes/user-activity-stream";
export default UserActivityStreamRoute.extend({
userActionType: Discourse.UserAction.TYPES.pending
});

View File

@ -168,7 +168,7 @@
{{#if canSeeNotificationHistory}} {{#if canSeeNotificationHistory}}
{{#link-to 'user.notifications' tagName="li"}} {{#link-to 'user.notifications' tagName="li"}}
{{#link-to 'user.notifications'}} {{#link-to 'user.notifications'}}
<i class='glyph fa fa-comment'></i> {{fa-icon "comment" class="glyph"}}
{{i18n 'user.notifications'}} {{i18n 'user.notifications'}}
<span class='count'>({{notification_count}})</span> <span class='count'>({{notification_count}})</span>
{{/link-to}} {{/link-to}}

View File

@ -8,16 +8,23 @@ class UserActionsController < ApplicationController
user = fetch_user_from_params user = fetch_user_from_params
opts = { opts = { user_id: user.id,
user_id: user.id, user: user,
offset: params[:offset].to_i, offset: params[:offset].to_i,
limit: per_chunk, limit: per_chunk,
action_types: (params[:filter] || "").split(",").map(&:to_i), action_types: (params[:filter] || "").split(",").map(&:to_i),
guardian: guardian, guardian: guardian,
ignore_private_messages: params[:filter] ? false : true ignore_private_messages: params[:filter] ? false : true }
}
render_serialized(UserAction.stream(opts), UserActionSerializer, root: "user_actions") # Pending is restricted
stream = if opts[:action_types].include?(UserAction::PENDING)
guardian.ensure_can_see_notifications!(user)
UserAction.stream_queued(opts)
else
UserAction.stream(opts)
end
render_serialized(stream, UserActionSerializer, root: "user_actions")
end end
def show def show

View File

@ -17,10 +17,12 @@ class UserAction < ActiveRecord::Base
EDIT = 11 EDIT = 11
NEW_PRIVATE_MESSAGE = 12 NEW_PRIVATE_MESSAGE = 12
GOT_PRIVATE_MESSAGE = 13 GOT_PRIVATE_MESSAGE = 13
PENDING = 14
ORDER = Hash[*[ ORDER = Hash[*[
GOT_PRIVATE_MESSAGE, GOT_PRIVATE_MESSAGE,
NEW_PRIVATE_MESSAGE, NEW_PRIVATE_MESSAGE,
PENDING,
NEW_TOPIC, NEW_TOPIC,
REPLY, REPLY,
RESPONSE, RESPONSE,
@ -56,15 +58,14 @@ class UserAction < ActiveRecord::Base
SELECT action_type, COUNT(*) count SELECT action_type, COUNT(*) count
FROM user_actions a FROM user_actions a
JOIN topics t ON t.id = a.target_topic_id LEFT JOIN topics t ON t.id = a.target_topic_id
LEFT JOIN posts p on p.id = a.target_post_id LEFT JOIN posts p on p.id = a.target_post_id
JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1 LEFT JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1
LEFT JOIN categories c ON c.id = t.category_id LEFT JOIN categories c ON c.id = t.category_id
/*where*/ /*where*/
GROUP BY action_type GROUP BY action_type
SQL SQL
builder.where('a.user_id = :user_id', user_id: user_id) builder.where('a.user_id = :user_id', user_id: user_id)
apply_common_filters(builder, user_id, guardian) apply_common_filters(builder, user_id, guardian)
@ -91,48 +92,82 @@ SQL
stream(action_id: action_id, guardian: guardian).first stream(action_id: action_id, guardian: guardian).first
end end
def self.stream(opts={}) def self.stream_queued(opts=nil)
user_id = opts[:user_id] opts ||= {}
offset = opts[:offset] || 0 offset = opts[:offset] || 0
limit = opts[:limit] || 60 limit = opts[:limit] || 60
action_id = opts[:action_id]
builder = SqlBuilder.new <<-SQL
SELECT
a.id,
t.title, a.action_type, a.created_at, t.id topic_id,
u.username, u.name, u.id AS user_id,
qp.raw,
t.category_id
FROM user_actions as a
JOIN queued_posts AS qp ON qp.id = a.queued_post_id
LEFT OUTER JOIN topics t on t.id = qp.topic_id
JOIN users u on u.id = a.user_id
LEFT JOIN categories c on c.id = t.category_id
/*where*/
/*order_by*/
/*offset*/
/*limit*/
SQL
builder
.where('a.user_id = :user_id', user_id: opts[:user_id].to_i)
.where('action_type = :pending', pending: UserAction::PENDING)
.order_by("a.created_at desc")
.offset(offset.to_i)
.limit(limit.to_i)
.map_exec(UserActionRow)
end
def self.stream(opts=nil)
opts ||= {}
action_types = opts[:action_types] action_types = opts[:action_types]
user_id = opts[:user_id]
action_id = opts[:action_id]
guardian = opts[:guardian] guardian = opts[:guardian]
ignore_private_messages = opts[:ignore_private_messages] ignore_private_messages = opts[:ignore_private_messages]
offset = opts[:offset] || 0
limit = opts[:limit] || 60
# The weird thing is that target_post_id can be null, so it makes everything # The weird thing is that target_post_id can be null, so it makes everything
# ever so more complex. Should we allow this, not sure. # ever so more complex. Should we allow this, not sure.
builder = SqlBuilder.new <<-SQL
builder = SqlBuilder.new(" SELECT
SELECT a.id,
a.id, t.title, a.action_type, a.created_at, t.id topic_id,
t.title, a.action_type, a.created_at, t.id topic_id, a.user_id AS target_user_id, au.name AS target_name, au.username AS target_username,
a.user_id AS target_user_id, au.name AS target_name, au.username AS target_username, coalesce(p.post_number, 1) post_number, p.id as post_id,
coalesce(p.post_number, 1) post_number, p.id as post_id, p.reply_to_post_number,
p.reply_to_post_number, pu.username, pu.name, pu.id user_id,
pu.email, pu.username, pu.name, pu.id user_id, pu.uploaded_avatar_id,
pu.uploaded_avatar_id, u.username acting_username, u.name acting_name, u.id acting_user_id,
u.email acting_email, u.username acting_username, u.name acting_name, u.id acting_user_id, u.uploaded_avatar_id acting_uploaded_avatar_id,
u.uploaded_avatar_id acting_uploaded_avatar_id, coalesce(p.cooked, p2.cooked) cooked,
coalesce(p.cooked, p2.cooked) cooked, CASE WHEN coalesce(p.deleted_at, p2.deleted_at, t.deleted_at) IS NULL THEN false ELSE true END deleted,
CASE WHEN coalesce(p.deleted_at, p2.deleted_at, t.deleted_at) IS NULL THEN false ELSE true END deleted, p.hidden,
p.hidden, p.post_type,
p.post_type, p.edit_reason,
p.edit_reason, t.category_id
t.category_id FROM user_actions as a
FROM user_actions as a JOIN topics t on t.id = a.target_topic_id
JOIN topics t on t.id = a.target_topic_id LEFT JOIN posts p on p.id = a.target_post_id
LEFT JOIN posts p on p.id = a.target_post_id JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1
JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1 JOIN users u on u.id = a.acting_user_id
JOIN users u on u.id = a.acting_user_id JOIN users pu on pu.id = COALESCE(p.user_id, t.user_id)
JOIN users pu on pu.id = COALESCE(p.user_id, t.user_id) JOIN users au on au.id = a.user_id
JOIN users au on au.id = a.user_id LEFT JOIN categories c on c.id = t.category_id
LEFT JOIN categories c on c.id = t.category_id /*where*/
/*where*/ /*order_by*/
/*order_by*/ /*offset*/
/*offset*/ /*limit*/
/*limit*/ SQL
")
apply_common_filters(builder, user_id, guardian, ignore_private_messages) apply_common_filters(builder, user_id, guardian, ignore_private_messages)
@ -151,7 +186,15 @@ LEFT JOIN categories c on c.id = t.category_id
end end
def self.log_action!(hash) def self.log_action!(hash)
required_parameters = [:action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id] required_parameters = [:action_type, :user_id, :acting_user_id]
if hash[:action_type] == UserAction::PENDING
required_parameters << :queued_post_id
else
required_parameters << :target_post_id
required_parameters << :target_topic_id
end
require_parameters(hash, *required_parameters) require_parameters(hash, *required_parameters)
transaction(requires_new: true) do transaction(requires_new: true) do
@ -269,6 +312,10 @@ SQL
builder.where("t.visible") builder.where("t.visible")
end end
unless guardian.can_see_notifications?(User.where(id: user_id).first)
builder.where('a.action_type <> :pending', pending: UserAction::PENDING)
end
if !guardian.can_see_private_messages?(user_id) || ignore_private_messages if !guardian.can_see_private_messages?(user_id) || ignore_private_messages
builder.where("t.archetype != :archetype", archetype: Archetype::private_message) builder.where("t.archetype != :archetype", archetype: Archetype::private_message)
end end

View File

@ -29,7 +29,8 @@ class UserActionSerializer < ApplicationSerializer
:acting_uploaded_avatar_id :acting_uploaded_avatar_id
def excerpt def excerpt
PrettyText.excerpt(object.cooked, 300) if object.cooked cooked = object.cooked || PrettyText.cook(object.raw)
PrettyText.excerpt(cooked, 300) if cooked
end end
def avatar_template def avatar_template
@ -40,6 +41,10 @@ class UserActionSerializer < ApplicationSerializer
User.avatar_template(object.acting_username, object.acting_uploaded_avatar_id) User.avatar_template(object.acting_username, object.acting_uploaded_avatar_id)
end end
def include_acting_avatar_template?
object.acting_username.present?
end
def include_name? def include_name?
SiteSetting.enable_names? SiteSetting.enable_names?
end end
@ -56,6 +61,10 @@ class UserActionSerializer < ApplicationSerializer
Slug.for(object.title) Slug.for(object.title)
end end
def include_slug?
object.title.present?
end
def moderator_action def moderator_action
object.post_type == Post.types[:moderator_action] object.post_type == Post.types[:moderator_action]
end end

View File

@ -64,7 +64,8 @@ class UserSerializer < BasicUserSerializer
:edit_history_public, :edit_history_public,
:custom_fields, :custom_fields,
:user_fields, :user_fields,
:topic_post_count :topic_post_count,
:pending_count
has_one :invited_by, embed: :object, serializer: BasicUserSerializer has_one :invited_by, embed: :object, serializer: BasicUserSerializer
has_many :custom_groups, embed: :object, serializer: BasicGroupSerializer has_many :custom_groups, embed: :object, serializer: BasicGroupSerializer
@ -312,4 +313,8 @@ class UserSerializer < BasicUserSerializer
{} {}
end end
end end
def pending_count
0
end
end end

View File

@ -308,6 +308,7 @@ en:
"11": "Edits" "11": "Edits"
"12": "Sent Items" "12": "Sent Items"
"13": "Inbox" "13": "Inbox"
"14": "Pending"
categories: categories:
all: "all categories" all: "all categories"

View File

@ -270,6 +270,7 @@ Discourse::Application.routes.draw do
get "users/:username/activity/:filter" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT} get "users/:username/activity/:filter" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT}
get "users/:username/badges" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT} get "users/:username/badges" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT}
get "users/:username/notifications" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT} get "users/:username/notifications" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT}
get "users/:username/pending" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT}
delete "users/:username" => "users#destroy", 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/by-external/:external_id" => "users#show"
get "users/:username/flagged-posts" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT} get "users/:username/flagged-posts" => "users#show", constraints: {username: USERNAME_ROUTE_FORMAT}

View File

@ -0,0 +1,5 @@
class AddQueuedPostIdToUserActions < ActiveRecord::Migration
def change
add_column :user_actions, :queued_post_id, :integer, null: true
end
end

View File

@ -23,7 +23,16 @@ class PostEnqueuer
return unless send(validate_method, queued_post.create_options) return unless send(validate_method, queued_post.create_options)
end end
add_errors_from(queued_post) unless queued_post.save if queued_post.save
UserAction.log_action!(action_type: UserAction::PENDING,
user_id: @user.id,
acting_user_id: @user.id,
target_topic_id: args[:topic_id],
queued_post_id: queued_post.id)
else
add_errors_from(queued_post)
end
queued_post queued_post
end end

View File

@ -10,6 +10,9 @@ describe PostEnqueuer do
let(:enqueuer) { PostEnqueuer.new(user, 'new_post') } let(:enqueuer) { PostEnqueuer.new(user, 'new_post') }
it 'enqueues the post' do it 'enqueues the post' do
old_count = user.user_actions.count
qp = enqueuer.enqueue(raw: 'This should be enqueued', qp = enqueuer.enqueue(raw: 'This should be enqueued',
topic_id: topic.id, topic_id: topic.id,
post_options: { reply_to_post_number: 1 }) post_options: { reply_to_post_number: 1 })
@ -18,6 +21,7 @@ describe PostEnqueuer do
expect(qp).to be_present expect(qp).to be_present
expect(qp.topic).to eq(topic) expect(qp.topic).to eq(topic)
expect(qp.user).to eq(user) expect(qp.user).to eq(user)
expect(UserAction.where(user_id: user.id).count).to eq(old_count + 1)
end end
end end

View File

@ -1,4 +1,5 @@
require 'spec_helper' require 'spec_helper'
require_dependency 'post_enqueuer'
describe UserActionsController do describe UserActionsController do
context 'index' do context 'index' do
@ -22,5 +23,35 @@ describe UserActionsController do
expect(action["email"]).to eq(nil) expect(action["email"]).to eq(nil)
expect(action["post_number"]).to eq(1) expect(action["post_number"]).to eq(1)
end end
context "queued posts" do
context "without access" do
let(:user) { Fabricate(:user) }
it "raises an exception" do
xhr :get, :index, username: user.username, filter: UserAction::PENDING
expect(response).to_not be_success
end
end
context "with access" do
let(:user) { log_in }
it 'finds queued posts' do
queued_post = PostEnqueuer.new(user, 'default').enqueue(raw: 'this is the raw enqueued content')
xhr :get, :index, username: user.username, filter: UserAction::PENDING
expect(response.status).to eq(200)
parsed = JSON.parse(response.body)
actions = parsed["user_actions"]
expect(actions.length).to eq(1)
action = actions.first
expect(action['username']).to eq(user.username)
expect(action['excerpt']).to be_present
end
end
end
end end
end end

View File

@ -0,0 +1,41 @@
import { acceptance } from "helpers/qunit-helpers";
acceptance("User Anonymous");
export function hasStream() {
andThen(() => {
ok(exists('.user-main .about'), 'it has the about section');
ok(count('.user-stream .item') > 0, 'it has stream items');
});
}
function hasTopicList() {
andThen(() => {
equal(count('.user-stream .item'), 0, "has no stream displayed");
ok(count('.topic-list tr') > 0, 'it has a topic list');
});
}
test("Filters", () => {
expect(14);
visit("/users/eviltrout");
hasStream();
visit("/users/eviltrout/activity/topics");
hasTopicList();
visit("/users/eviltrout/activity/posts");
hasStream();
visit("/users/eviltrout/activity/replies");
hasStream();
visit("/users/eviltrout/activity/likes-given");
hasStream();
visit("/users/eviltrout/activity/likes-received");
hasStream();
visit("/users/eviltrout/activity/edits");
hasStream();
});

View File

@ -1,41 +1,9 @@
import { acceptance } from "helpers/qunit-helpers"; import { acceptance } from "helpers/qunit-helpers";
acceptance("User"); import { hasStream } from 'acceptance/user-anonymous-test';
function hasStream() { acceptance("User", {loggedIn: true});
andThen(() => {
ok(exists('.user-main .about'), 'it has the about section');
ok(count('.user-stream .item') > 0, 'it has stream items');
});
}
function hasTopicList() { test("Pending", () => {
andThen(() => { visit("/users/eviltrout/activity/pending");
equal(count('.user-stream .item'), 0, "has no stream displayed");
ok(count('.topic-list tr') > 0, 'it has a topic list');
});
}
test("Filters", () => {
expect(14);
visit("/users/eviltrout");
hasStream();
visit("/users/eviltrout/activity/topics");
hasTopicList();
visit("/users/eviltrout/activity/posts");
hasStream();
visit("/users/eviltrout/activity/replies");
hasStream();
visit("/users/eviltrout/activity/likes-given");
hasStream();
visit("/users/eviltrout/activity/likes-received");
hasStream();
visit("/users/eviltrout/activity/edits");
hasStream(); hasStream();
}); });

View File

@ -101,6 +101,8 @@ export default function() {
this.delete('/draft.json', success); this.delete('/draft.json', success);
this.get('/users/:username/staff-info.json', () => response({}));
this.get('/draft.json', function() { this.get('/draft.json', function() {
return response({}); return response({});
}); });