PostEnqueuer object to handle validation of enqueued posts

This commit is contained in:
Robin Ward 2015-03-26 16:57:50 -04:00
parent 8ba6a45cd7
commit a5ee45ccbe
14 changed files with 348 additions and 165 deletions

View File

@ -369,7 +369,9 @@ class Post < ActiveRecord::Base
problems problems
end end
def rebake!(opts={}) def rebake!(opts=nil)
opts ||= {}
new_cooked = cook( new_cooked = cook(
raw, raw,
topic_id: topic_id, topic_id: topic_id,

View File

@ -2,8 +2,6 @@ class QueuedPost < ActiveRecord::Base
class InvalidStateTransition < StandardError; end; class InvalidStateTransition < StandardError; end;
serialize :post_options, JSON
belongs_to :user belongs_to :user
belongs_to :topic belongs_to :topic
belongs_to :approved_by, class_name: "User" belongs_to :approved_by, class_name: "User"
@ -36,6 +34,8 @@ class QueuedPost < ActiveRecord::Base
opts = {raw: raw} opts = {raw: raw}
post_attributes.each {|a| opts[a] = post_options[a.to_s] } post_attributes.each {|a| opts[a] = post_options[a.to_s] }
opts[:cooking_options].symbolize_keys! if opts[:cooking_options]
opts[:topic_id] = topic_id if topic_id opts[:topic_id] = topic_id if topic_id
opts opts
end end
@ -52,6 +52,7 @@ class QueuedPost < ActiveRecord::Base
end end
private private
def post_attributes def post_attributes
[QueuedPost.attributes_by_queue[:base], QueuedPost.attributes_by_queue[queue.to_sym]].flatten.compact [QueuedPost.attributes_by_queue[:base], QueuedPost.attributes_by_queue[queue.to_sym]].flatten.compact
end end

View File

@ -1,6 +1,6 @@
class CreateDirectoryItems < ActiveRecord::Migration class CreateDirectoryItems < ActiveRecord::Migration
def change def change
create_table :directory_items do |t| create_table :directory_items, force: true do |t|
t.integer :period_type, null: false t.integer :period_type, null: false
t.references :user, null: false t.references :user, null: false
t.integer :likes_received, null: false t.integer :likes_received, null: false

View File

@ -1,6 +1,6 @@
class AddMutedUsers < ActiveRecord::Migration class AddMutedUsers < ActiveRecord::Migration
def change def change
create_table :muted_users do |t| create_table :muted_users, force: true do |t|
t.integer :user_id, null: false t.integer :user_id, null: false
t.integer :muted_user_id, null: false t.integer :muted_user_id, null: false
t.timestamps t.timestamps

View File

@ -1,11 +1,11 @@
class CreateQueuedPosts < ActiveRecord::Migration class CreateQueuedPosts < ActiveRecord::Migration
def change def change
create_table :queued_posts do |t| create_table :queued_posts, force: true do |t|
t.string :queue, null: false t.string :queue, null: false
t.integer :state, null: false t.integer :state, null: false
t.integer :user_id, null: false t.integer :user_id, null: false
t.text :raw, null: false t.text :raw, null: false
t.text :post_options, null: false t.json :post_options, null: false
t.integer :topic_id t.integer :topic_id
t.integer :approved_by_id t.integer :approved_by_id
t.timestamp :approved_at t.timestamp :approved_at
@ -15,6 +15,6 @@ class CreateQueuedPosts < ActiveRecord::Migration
end end
add_index :queued_posts, [:queue, :state, :created_at], name: 'by_queue_status' add_index :queued_posts, [:queue, :state, :created_at], name: 'by_queue_status'
add_index :queued_posts, [:topic, :queue, :state, :created_at], name: 'by_queue_status_topic' add_index :queued_posts, [:topic_id, :queue, :state, :created_at], name: 'by_queue_status_topic'
end end
end end

31
lib/has_errors.rb Normal file
View File

@ -0,0 +1,31 @@
# Helper functions for dealing with errors and objects that have
# child objects with errors
module HasErrors
def errors
@errors ||= ActiveModel::Errors.new(self)
end
def validate_child(obj)
return true if obj.valid?
add_errors_from(obj)
false
end
def rollback_with!(obj, error)
obj.errors[:base] << error
rollback_from_errors!(obj)
end
def rollback_from_errors!(obj)
add_errors_from(obj)
raise ActiveRecord::Rollback.new
end
def add_errors_from(obj)
obj.errors.full_messages.each do |msg|
errors[:base] << msg unless errors[:base].include?(msg)
end
end
end

View File

@ -4,10 +4,12 @@ require_dependency 'rate_limiter'
require_dependency 'topic_creator' require_dependency 'topic_creator'
require_dependency 'post_jobs_enqueuer' require_dependency 'post_jobs_enqueuer'
require_dependency 'distributed_mutex' require_dependency 'distributed_mutex'
require_dependency 'has_errors'
class PostCreator class PostCreator
include HasErrors
attr_reader :errors, :opts attr_reader :opts
# Acceptable options: # Acceptable options:
# #
@ -66,21 +68,50 @@ class PostCreator
@guardian ||= Guardian.new(@user) @guardian ||= Guardian.new(@user)
end end
def create def valid?
@topic = nil @topic = nil
@post = nil @post = nil
if @user.suspended? && !skip_validations? if @user.suspended? && !skip_validations?
@errors = Post.new.errors errors[:base] << I18n.t(:user_is_suspended)
@errors.add(:base, I18n.t(:user_is_suspended)) return false
return end
if new_topic?
topic_creator = TopicCreator.new(@user, guardian, @opts)
return false unless skip_validations? || validate_child(topic_creator)
else
@topic = Topic.find_by(id: @opts[:topic_id])
if (@topic.blank? || !guardian.can_create?(Post, @topic))
errors[:base] << I18n.t(:topic_not_found)
return false
end
end end
transaction do
setup_topic
setup_post setup_post
rollback_if_host_spam_detected
plugin_callbacks return true if skip_validations?
if @post.has_host_spam?
@spam = true
errors[:base] << I18n.t(:spamming_host)
return false
end
DiscourseEvent.trigger :before_create_post, @post
DiscourseEvent.trigger :validate_post, @post
post_validator = Validators::PostValidator.new(skip_topic: true)
post_validator.validate(@post)
valid = @post.errors.blank?
add_errors_from(@post) unless valid
valid
end
def create
if valid?
transaction do
create_topic
save_post save_post
extract_links extract_links
store_unique_post_key store_unique_post_key
@ -94,8 +125,9 @@ class PostCreator
@post.advance_draft_sequence @post.advance_draft_sequence
@post.save_reply_relationships @post.save_reply_relationships
end end
end
if @post && @post.errors.empty? if @post && errors.blank?
publish publish
PostAlerter.post_created(@post) unless @opts[:import_mode] PostAlerter.post_created(@post) unless @opts[:import_mode]
@ -164,16 +196,11 @@ class PostCreator
{ user: @user, { user: @user,
limit_once_per: 24.hours, limit_once_per: 24.hours,
message_params: {domains: @post.linked_hosts.keys.join(', ')} } ) message_params: {domains: @post.linked_hosts.keys.join(', ')} } )
elsif @post && !@post.errors.present? && !skip_validations? elsif @post && errors.blank? && !skip_validations?
SpamRulesEnforcer.enforce!(@post) SpamRulesEnforcer.enforce!(@post)
end end
end end
def plugin_callbacks
DiscourseEvent.trigger :before_create_post, @post
DiscourseEvent.trigger :validate_post, @post
end
def track_latest_on_category def track_latest_on_category
return unless @post && @post.errors.count == 0 && @topic && @topic.category_id return unless @post && @post.errors.count == 0 && @topic && @topic.category_id
@ -191,27 +218,17 @@ class PostCreator
private private
def setup_topic def create_topic
if new_topic? return if @topic
topic_creator = TopicCreator.new(@user, guardian, @opts)
begin begin
topic = topic_creator.create topic_creator = TopicCreator.new(@user, guardian, @opts)
@errors = topic_creator.errors @topic = topic_creator.create
rescue ActiveRecord::Rollback => ex rescue ActiveRecord::Rollback
# In the event of a rollback, grab the errors from the topic add_errors_from(topic_creator)
@errors = topic_creator.errors return
raise ex
end end
else @post.topic_id = @topic.id
topic = Topic.find_by(id: @opts[:topic_id]) @post.topic = @topic
if (topic.blank? || !guardian.can_create?(Post, topic))
@errors = Post.new.errors
@errors.add(:base, I18n.t(:topic_not_found))
raise ActiveRecord::Rollback.new
end
end
@topic = topic
end end
def update_topic_stats def update_topic_stats
@ -232,7 +249,8 @@ class PostCreator
def setup_post def setup_post
@opts[:raw] = TextCleaner.normalize_whitespaces(@opts[:raw]).gsub(/\s+\z/, "") @opts[:raw] = TextCleaner.normalize_whitespaces(@opts[:raw]).gsub(/\s+\z/, "")
post = @topic.posts.new(raw: @opts[:raw], post = Post.new(raw: @opts[:raw],
topic_id: @topic.try(:id),
user: @user, user: @user,
reply_to_post_number: @opts[:reply_to_post_number]) reply_to_post_number: @opts[:reply_to_post_number])
@ -251,21 +269,9 @@ class PostCreator
@post = post @post = post
end end
def rollback_if_host_spam_detected
return if skip_validations?
if @post.has_host_spam?
@post.errors.add(:base, I18n.t(:spamming_host))
@errors = @post.errors
@spam = true
raise ActiveRecord::Rollback.new
end
end
def save_post def save_post
unless @post.save(validate: !skip_validations?) saved = @post.save(validate: !skip_validations?)
@errors = @post.errors rollback_from_errors!(@post) unless saved
raise ActiveRecord::Rollback.new
end
end end
def store_unique_post_key def store_unique_post_key

41
lib/post_enqueuer.rb Normal file
View File

@ -0,0 +1,41 @@
require_dependency 'topic_creator'
require_dependency 'queued_post'
require_dependency 'has_errors'
class PostEnqueuer
include HasErrors
def initialize(user, queue)
@user = user
@queue = queue
end
def enqueue(args)
queued_post = QueuedPost.new(queue: @queue,
state: QueuedPost.states[:new],
user_id: @user.id,
topic_id: args[:topic_id],
raw: args[:raw],
post_options: args[:post_options] || {})
validate_method = :"validate_#{@queue}"
if respond_to?(validate_method)
return unless send(validate_method, queued_post.create_options)
end
add_errors_from(queued_post) unless queued_post.save
queued_post
end
def validate_new_topic(create_options)
topic_creator = TopicCreator.new(@user, Guardian.new(@user), create_options)
validate_child(topic_creator)
end
def validate_new_post(create_options)
post_creator = PostCreator.new(@user, create_options)
validate_child(post_creator)
end
end

View File

@ -1,6 +1,7 @@
class TopicCreator require_dependency 'has_errors'
attr_accessor :errors class TopicCreator
include HasErrors
def self.create(user, guardian, opts) def self.create(user, guardian, opts)
self.new(user, guardian, opts).create self.new(user, guardian, opts).create
@ -13,55 +14,52 @@ class TopicCreator
@added_users = [] @added_users = []
end end
def valid?
topic = Topic.new(setup_topic_params)
validate_child(topic)
end
def create def create
@topic = Topic.new(setup_topic_params) topic = Topic.new(setup_topic_params)
setup_auto_close_time setup_auto_close_time(topic)
process_private_message process_private_message(topic)
save_topic save_topic(topic)
create_warning create_warning(topic)
watch_topic watch_topic(topic)
@topic topic
end end
private private
def create_warning def create_warning(topic)
return unless @opts[:is_warning] return unless @opts[:is_warning]
# We can only attach warnings to PMs # We can only attach warnings to PMs
unless @topic.private_message? rollback_with!(topic, :warning_requires_pm) unless topic.private_message?
@topic.errors.add(:base, :warning_requires_pm)
@errors = @topic.errors
raise ActiveRecord::Rollback.new
end
# Don't create it if there is more than one user # Don't create it if there is more than one user
if @added_users.size != 1 rollback_with!(topic, :too_many_users) if @added_users.size != 1
@topic.errors.add(:base, :too_many_users)
@errors = @topic.errors
raise ActiveRecord::Rollback.new
end
# Create a warning record # Create a warning record
Warning.create(topic: @topic, user: @added_users.first, created_by: @user) Warning.create(topic: topic, user: @added_users.first, created_by: @user)
end end
def watch_topic def watch_topic(topic)
unless @opts[:auto_track] == false unless @opts[:auto_track] == false
@topic.notifier.watch_topic!(@topic.user_id) topic.notifier.watch_topic!(topic.user_id)
end end
user_ids = @topic.topic_allowed_users(true).pluck(:user_id) user_ids = topic.topic_allowed_users(true).pluck(:user_id)
user_ids += @topic.topic_allowed_groups(true).map { |t| t.group.users.pluck(:id) }.flatten user_ids += topic.topic_allowed_groups(true).map { |t| t.group.users.pluck(:id) }.flatten
user_ids.uniq.reject{ |id| id == @topic.user_id }.each do |user_id| user_ids.uniq.reject{ |id| id == topic.user_id }.each do |user_id|
@topic.notifier.watch_topic!(user_id, nil) unless user_id == -1 topic.notifier.watch_topic!(user_id, nil) unless user_id == -1
end end
CategoryUser.auto_watch_new_topic(@topic) CategoryUser.auto_watch_new_topic(topic)
CategoryUser.auto_track_new_topic(@topic) CategoryUser.auto_track_new_topic(topic)
end end
def setup_topic_params def setup_topic_params
@ -106,31 +104,28 @@ class TopicCreator
end end
end end
def setup_auto_close_time def setup_auto_close_time(topic)
return unless @opts[:auto_close_time].present? return unless @opts[:auto_close_time].present?
return unless @guardian.can_moderate?(@topic) return unless @guardian.can_moderate?(topic)
@topic.set_auto_close(@opts[:auto_close_time], @user) topic.set_auto_close(@opts[:auto_close_time], @user)
end end
def process_private_message def process_private_message(topic)
return unless @opts[:archetype] == Archetype.private_message return unless @opts[:archetype] == Archetype.private_message
@topic.subtype = TopicSubtype.user_to_user unless @topic.subtype topic.subtype = TopicSubtype.user_to_user unless topic.subtype
unless @opts[:target_usernames].present? || @opts[:target_group_names].present? unless @opts[:target_usernames].present? || @opts[:target_group_names].present?
@topic.errors.add(:base, :no_user_selected) rollback_with!(topic, :no_user_selected)
@errors = @topic.errors
raise ActiveRecord::Rollback.new
end end
add_users(@topic,@opts[:target_usernames]) add_users(topic,@opts[:target_usernames])
add_groups(@topic,@opts[:target_group_names]) add_groups(topic,@opts[:target_group_names])
@topic.topic_allowed_users.build(user_id: @user.id) topic.topic_allowed_users.build(user_id: @user.id)
end end
def save_topic def save_topic(topic)
unless @topic.save(validate: !@opts[:skip_validations]) unless topic.save(validate: !@opts[:skip_validations])
@errors = @topic.errors rollback_from_errors!(topic)
raise ActiveRecord::Rollback.new
end end
end end
@ -146,16 +141,12 @@ class TopicCreator
def add_groups(topic, groups) def add_groups(topic, groups)
return unless groups return unless groups
Group.where(name: groups.split(',')).each do |group| Group.where(name: groups.split(',')).each do |group|
check_can_send_permission!(topic,group) check_can_send_permission!(topic, group)
topic.topic_allowed_groups.build(group_id: group.id) topic.topic_allowed_groups.build(group_id: group.id)
end end
end end
def check_can_send_permission!(topic,item) def check_can_send_permission!(topic, obj)
unless @guardian.can_send_private_message?(item) rollback_with!(topic, :cant_send_pm) unless @guardian.can_send_private_message?(obj)
topic.errors.add(:base, :cant_send_pm)
@errors = topic.errors
raise ActiveRecord::Rollback.new
end
end end
end end

View File

@ -1,6 +1,7 @@
require_dependency 'validators/stripped_length_validator' require_dependency 'validators/stripped_length_validator'
module Validators; end module Validators; end
class Validators::PostValidator < ActiveModel::Validator class Validators::PostValidator < ActiveModel::Validator
def validate(record) def validate(record)
presence(record) presence(record)
unless Discourse.static_doc_topic_ids.include?(record.topic_id) && record.acting_user.try(:admin?) unless Discourse.static_doc_topic_ids.include?(record.topic_id) && record.acting_user.try(:admin?)
@ -16,9 +17,12 @@ class Validators::PostValidator < ActiveModel::Validator
end end
def presence(post) def presence(post)
[:raw,:topic_id].each do |attr_name|
post.errors.add(attr_name, :blank, options) if post.send(attr_name).blank? post.errors.add(:raw, :blank, options) if post.raw.blank?
unless options[:skip_topic]
post.errors.add(:topic_id, :blank, options) if post.topic_id.blank?
end end
if post.new_record? and post.user_id.nil? if post.new_record? and post.user_id.nil?
post.errors.add(:user_id, :blank, options) post.errors.add(:user_id, :blank, options)
end end

View File

@ -0,0 +1,55 @@
require 'spec_helper'
require 'has_errors'
describe HasErrors do
class ErrorTestClass
include HasErrors
end
let(:error_test) { ErrorTestClass.new }
let(:title_error) { "Title can't be blank" }
# No title is invalid
let(:invalid_topic) { Fabricate.build(:topic, title: '') }
it "has no errors by default" do
expect(error_test.errors).to be_blank
end
context "validate_child" do
it "adds the errors from invalid AR objects" do
expect(error_test.validate_child(invalid_topic)).to eq(false)
expect(error_test.errors).to be_present
expect(error_test.errors[:base]).to include(title_error)
end
it "doesn't add the errors from valid AR objects" do
topic = Fabricate.build(:topic)
expect(error_test.validate_child(topic)).to eq(true)
expect(error_test.errors).to be_blank
end
end
context "rollback_from_errors!" do
it "triggers a rollback" do
invalid_topic.valid?
expect(-> { error_test.rollback_from_errors!(invalid_topic) }).to raise_error(ActiveRecord::Rollback)
expect(error_test.errors).to be_present
expect(error_test.errors[:base]).to include(title_error)
end
end
context "rollback_with_error!" do
it "triggers a rollback" do
expect(-> {
error_test.rollback_with!(invalid_topic, :custom_error)
}).to raise_error(ActiveRecord::Rollback)
expect(error_test.errors).to be_present
expect(error_test.errors[:base]).to include(:custom_error)
end
end
end

View File

@ -311,7 +311,8 @@ describe PostCreator do
it "does not create the post" do it "does not create the post" do
GroupMessage.stubs(:create) GroupMessage.stubs(:create)
creator.create post = creator.create
expect(creator.errors).to be_present expect(creator.errors).to be_present
expect(creator.spam?).to eq(true) expect(creator.spam?).to eq(true)
end end
@ -521,7 +522,7 @@ describe PostCreator do
it 'can save a post' do it 'can save a post' do
creator = PostCreator.new(user, raw: 'q', title: 'q', skip_validations: true) creator = PostCreator.new(user, raw: 'q', title: 'q', skip_validations: true)
creator.create creator.create
expect(creator.errors).to eq(nil) expect(creator.errors).to be_blank
end end
end end
@ -573,7 +574,8 @@ describe PostCreator do
end end
it "doesn't strip starting whitespaces" do it "doesn't strip starting whitespaces" do
post = PostCreator.new(user, { title: "testing whitespace stripping", raw: " <-- whitespaces --> " }).create pc = PostCreator.new(user, { title: "testing whitespace stripping", raw: " <-- whitespaces --> " })
post = pc.create
expect(post.raw).to eq(" <-- whitespaces -->") expect(post.raw).to eq(" <-- whitespaces -->")
end end

View File

@ -0,0 +1,47 @@
require 'spec_helper'
require_dependency 'post_enqueuer'
describe PostEnqueuer do
let(:user) { Fabricate(:user) }
context 'with valid arguments' do
let(:topic) { Fabricate(:topic) }
let(:enqueuer) { PostEnqueuer.new(user, 'new_post') }
it 'enqueues the post' do
qp = enqueuer.enqueue(raw: 'This should be enqueued',
topic_id: topic.id,
post_options: { reply_to_post_number: 1 })
expect(enqueuer.errors).to be_blank
expect(qp).to be_present
expect(qp.topic).to eq(topic)
expect(qp.user).to eq(user)
end
end
context "topic validations" do
let(:enqueuer) { PostEnqueuer.new(user, 'new_topic') }
it "doesn't enqueue the post" do
qp = enqueuer.enqueue(raw: 'This should not be enqueued',
post_options: { title: 'too short' })
expect(enqueuer.errors).to be_present
expect(qp).to be_blank
end
end
context "post validations" do
let(:enqueuer) { PostEnqueuer.new(user, 'new_post') }
it "doesn't enqueue the post" do
qp = enqueuer.enqueue(raw: 'too short')
expect(enqueuer.errors).to be_present
expect(qp).to be_blank
end
end
end

View File

@ -19,7 +19,7 @@ describe QueuedPost do
auto_track: true, auto_track: true,
custom_fields: { hello: 'world' }, custom_fields: { hello: 'world' },
cooking_options: { cat: 'hat' }, cooking_options: { cat: 'hat' },
cook_method: 'regular', cook_method: Post.cook_methods[:raw_html],
not_create_option: true, not_create_option: true,
image_sizes: {"http://foo.bar/image.png" => {"width" => 0, "height" => 222}} image_sizes: {"http://foo.bar/image.png" => {"width" => 0, "height" => 222}}
}) } }) }
@ -34,8 +34,8 @@ describe QueuedPost do
expect(create_options[:raw_email]).to eq('store_me') expect(create_options[:raw_email]).to eq('store_me')
expect(create_options[:auto_track]).to eq(true) expect(create_options[:auto_track]).to eq(true)
expect(create_options[:custom_fields]).to eq('hello' => 'world') expect(create_options[:custom_fields]).to eq('hello' => 'world')
expect(create_options[:cooking_options]).to eq('cat' => 'hat') expect(create_options[:cooking_options]).to eq(cat: 'hat')
expect(create_options[:cook_method]).to eq('regular') expect(create_options[:cook_method]).to eq(Post.cook_methods[:raw_html])
expect(create_options[:not_create_option]).to eq(nil) expect(create_options[:not_create_option]).to eq(nil)
expect(create_options[:image_sizes]).to eq("http://foo.bar/image.png" => {"width" => 0, "height" => 222}) expect(create_options[:image_sizes]).to eq("http://foo.bar/image.png" => {"width" => 0, "height" => 222})
end end
@ -47,6 +47,7 @@ describe QueuedPost do
expect(post).to be_present expect(post).to be_present
expect(post).to be_valid expect(post).to be_valid
expect(post.topic).to eq(topic) expect(post.topic).to eq(topic)
expect(post.custom_fields['hello']).to eq('world')
# Updates the QP record # Updates the QP record
expect(qp.approved_by).to eq(admin) expect(qp.approved_by).to eq(admin)
@ -74,8 +75,9 @@ describe QueuedPost do
context "creating a topic" do context "creating a topic" do
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:admin) { Fabricate(:admin) } let(:admin) { Fabricate(:admin) }
let!(:category) { Fabricate(:category) }
context "with a valid topic" do
let!(:category) { Fabricate(:category) }
let(:qp) { QueuedPost.create(queue: 'new_topic', let(:qp) { QueuedPost.create(queue: 'new_topic',
state: QueuedPost.states[:new], state: QueuedPost.states[:new],
user_id: user.id, user_id: user.id,
@ -120,5 +122,6 @@ describe QueuedPost do
expect(Post.count).to eq(post_count) expect(Post.count).to eq(post_count)
end end
end end
end
end end