diff --git a/app/models/queued_post.rb b/app/models/queued_post.rb new file mode 100644 index 00000000000..a7b4dcec448 --- /dev/null +++ b/app/models/queued_post.rb @@ -0,0 +1,77 @@ +class QueuedPost < ActiveRecord::Base + + class InvalidStateTransition < StandardError; end; + + serialize :post_options, JSON + + belongs_to :user + belongs_to :topic + belongs_to :approved_by, class_name: "User" + belongs_to :rejected_by, class_name: "User" + + def self.attributes_by_queue + @attributes_by_queue ||= { + base: [:archetype, + :via_email, + :raw_email, + :auto_track, + :custom_fields, + :cooking_options, + :cook_method, + :image_sizes], + new_post: [:reply_to_post_number], + new_topic: [:title, :category, :meta_data, :archetype], + } + end + + def self.states + @states ||= Enum.new(:new, :approved, :rejected) + end + + def reject!(rejected_by) + change_to!(:rejected, rejected_by) + end + + def create_options + opts = {raw: raw} + post_attributes.each {|a| opts[a] = post_options[a.to_s] } + + opts[:topic_id] = topic_id if topic_id + opts + end + + def approve!(approved_by) + created_post = nil + QueuedPost.transaction do + change_to!(:approved, approved_by) + + creator = PostCreator.new(user, create_options) + created_post = creator.create + end + created_post + end + + private + def post_attributes + [QueuedPost.attributes_by_queue[:base], QueuedPost.attributes_by_queue[queue.to_sym]].flatten.compact + end + + def change_to!(state, changed_by) + state_val = QueuedPost.states[state] + + updates = { state: state_val, + "#{state}_by_id" => changed_by.id, + "#{state}_at" => Time.now } + + # We use an update with `row_count` trick here to avoid stampeding requests to + # update the same row simultaneously. Only one state change should go through and + # we can use the DB to enforce this + row_count = QueuedPost.where('id = ? AND state <> ?', id, state_val).update_all(updates) + raise InvalidStateTransition.new if row_count == 0 + + # Update the record in memory too, and clear the dirty flag + updates.each {|k, v| send("#{k}=", v) } + changes_applied + end + +end diff --git a/db/migrate/20150325160959_create_queued_posts.rb b/db/migrate/20150325160959_create_queued_posts.rb new file mode 100644 index 00000000000..4941753ee2d --- /dev/null +++ b/db/migrate/20150325160959_create_queued_posts.rb @@ -0,0 +1,20 @@ +class CreateQueuedPosts < ActiveRecord::Migration + def change + create_table :queued_posts do |t| + t.string :queue, null: false + t.integer :state, null: false + t.integer :user_id, null: false + t.text :raw, null: false + t.text :post_options, null: false + t.integer :topic_id + t.integer :approved_by_id + t.timestamp :approved_at + t.integer :rejected_by_id + t.timestamp :rejected_at + t.timestamps + end + + 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' + end +end diff --git a/spec/models/queued_post_spec.rb b/spec/models/queued_post_spec.rb new file mode 100644 index 00000000000..7fa07b19910 --- /dev/null +++ b/spec/models/queued_post_spec.rb @@ -0,0 +1,124 @@ +require 'spec_helper' +require_dependency 'queued_post' + +describe QueuedPost do + + context "creating a post" do + let(:topic) { Fabricate(:topic) } + let(:user) { Fabricate(:user) } + let(:admin) { Fabricate(:admin) } + let(:qp) { QueuedPost.create(queue: 'new_post', + state: QueuedPost.states[:new], + user_id: user.id, + topic_id: topic.id, + raw: 'This post should be queued up', + post_options: { + reply_to_post_number: 1, + via_email: true, + raw_email: 'store_me', + auto_track: true, + custom_fields: { hello: 'world' }, + cooking_options: { cat: 'hat' }, + cook_method: 'regular', + not_create_option: true, + image_sizes: {"http://foo.bar/image.png" => {"width" => 0, "height" => 222}} + }) } + + it "returns the appropriate options for posting" do + create_options = qp.create_options + + expect(create_options[:topic_id]).to eq(topic.id) + expect(create_options[:raw]).to eq('This post should be queued up') + expect(create_options[:reply_to_post_number]).to eq(1) + expect(create_options[:via_email]).to eq(true) + expect(create_options[:raw_email]).to eq('store_me') + expect(create_options[:auto_track]).to eq(true) + expect(create_options[:custom_fields]).to eq('hello' => 'world') + expect(create_options[:cooking_options]).to eq('cat' => 'hat') + expect(create_options[:cook_method]).to eq('regular') + 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}) + end + + it "follows the correct workflow for approval" do + post = qp.approve!(admin) + + # Creates the post with the attributes + expect(post).to be_present + expect(post).to be_valid + expect(post.topic).to eq(topic) + + # Updates the QP record + expect(qp.approved_by).to eq(admin) + expect(qp.state).to eq(QueuedPost.states[:approved]) + expect(qp.approved_at).to be_present + + # We can't approve twice + expect(-> { qp.approve!(admin) }).to raise_error(QueuedPost::InvalidStateTransition) + + end + + it "follows the correct workflow for rejection" do + qp.reject!(admin) + + # Updates the QP record + expect(qp.rejected_by).to eq(admin) + expect(qp.state).to eq(QueuedPost.states[:rejected]) + expect(qp.rejected_at).to be_present + + # We can't reject twice + expect(-> { qp.reject!(admin) }).to raise_error(QueuedPost::InvalidStateTransition) + end + end + + context "creating a topic" do + let(:user) { Fabricate(:user) } + let(:admin) { Fabricate(:admin) } + let!(:category) { Fabricate(:category) } + + let(:qp) { QueuedPost.create(queue: 'new_topic', + state: QueuedPost.states[:new], + user_id: user.id, + raw: 'This post should be queued up', + post_options: { + title: 'This is the topic title to queue up', + archetype: 'regular', + category: category.id, + meta_data: {evil: 'trout'} + }) } + + + it "returns the appropriate options for creating a topic" do + create_options = qp.create_options + + expect(create_options[:category]).to eq(category.id) + expect(create_options[:archetype]).to eq('regular') + expect(create_options[:meta_data]).to eq('evil' => 'trout') + end + + it "creates the post and topic" do + topic_count, post_count = Topic.count, Post.count + post = qp.approve!(admin) + + expect(Topic.count).to eq(topic_count + 1) + expect(Post.count).to eq(post_count + 1) + + expect(post).to be_present + expect(post).to be_valid + + topic = post.topic + expect(topic).to be_present + expect(topic.category).to eq(category) + end + + it "doesn't create the post and topic" do + topic_count, post_count = Topic.count, Post.count + + qp.reject!(admin) + + expect(Topic.count).to eq(topic_count) + expect(Post.count).to eq(post_count) + end + end + +end