diff --git a/plugins/poll/config/locales/server.en.yml b/plugins/poll/config/locales/server.en.yml index a270072f3..6e1e8829a 100644 --- a/plugins/poll/config/locales/server.en.yml +++ b/plugins/poll/config/locales/server.en.yml @@ -5,7 +5,12 @@ # http://yamllint.com/ en: + activerecord: + attributes: + post: + poll_options: "Poll options" poll: must_contain_poll_options: "must contain a list of poll options" - cannot_have_modified_options: "cannot have modified poll options after 5 minutes" + cannot_have_modified_options: "cannot be modified after the first five minutes. Contact a moderator if you need to change them." + cannot_add_or_remove_options: "can only be edited, not added or removed. If you need to add or remove options you should lock this thread and create a new one." prefix: "Poll:" diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index a959d1bab..454a6835a 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -6,8 +6,7 @@ load File.expand_path("../poll.rb", __FILE__) # Without this line we can't lookup the constant inside the after_initialize blocks, -# probably because all of this is instance_eval'd inside an instance of -# Plugin::Instance. +# because all of this is instance_eval'd inside an instance of Plugin::Instance. PollPlugin = PollPlugin after_initialize do @@ -64,8 +63,6 @@ after_initialize do # Starting a topic title with "Poll:" will create a poll topic. If the title # starts with "poll:" but the first post doesn't contain a list of options in # it we need to raise an error. - # Need to add an error when: - # * there is no list of options. Post.class_eval do validate :poll_options def poll_options @@ -77,30 +74,15 @@ after_initialize do self.errors.add(:raw, I18n.t('poll.must_contain_poll_options')) end - if self.created_at and self.created_at < 5.minutes.ago and poll.options.sort != poll.details.keys.sort - self.errors.add(:raw, I18n.t('poll.cannot_have_modified_options')) - end + poll.ensure_can_be_edited! end end # Save the list of options to PluginStore after the post is saved. Post.class_eval do - after_save :save_poll_options_to_topic_metadata - def save_poll_options_to_topic_metadata - poll = PollPlugin::Poll.new(self) - if poll.is_poll? - details = poll.details || {} - new_options = poll.options - details.each do |key, value| - unless new_options.include? key - details.delete(key) - end - end - new_options.each do |key| - details[key] ||= 0 - end - poll.set_details! details - end + after_save :save_poll_options_to_plugin_store + def save_poll_options_to_plugin_store + PollPlugin::Poll.new(self).update_options! end end diff --git a/plugins/poll/poll.rb b/plugins/poll/poll.rb index 5751e9e41..1261e1ca7 100644 --- a/plugins/poll/poll.rb +++ b/plugins/poll/poll.rb @@ -24,6 +24,29 @@ module ::PollPlugin topic.title =~ /^#{I18n.t('poll.prefix')}/i end + # Called during validation of poll posts. Discourse already restricts edits to + # the OP and staff, we want to make sure that: + # + # * OP cannot edit options after 5 minutes. + # * Staff can only edit options after 5 minutes, not add/remove. + def ensure_can_be_edited! + # Return if this is a new post or the options were not modified. + return if @post.id.nil? || (options.sort == details.keys.sort) + + # First 5 minutes -- allow any modification. + return unless @post.created_at < 5.minutes.ago + + if User.find(@post.last_editor_id).staff? + # Allow editing options, but not adding or removing. + if options.length != details.keys.length + @post.errors.add(:poll_options, I18n.t('poll.cannot_add_or_remove_options')) + end + else + # Regular user, tell them to contact a moderator. + @post.errors.add(:poll_options, I18n.t('poll.cannot_have_modified_options')) + end + end + def options cooked = PrettyText.cook(@post.raw, topic_id: @post.topic_id) parsed = Nokogiri::HTML(cooked) @@ -35,6 +58,58 @@ module ::PollPlugin end end + def update_options! + return unless self.is_poll? + return if details && details.keys.sort == options.sort + + if details.try(:length) == options.length + + # Assume only renaming, no reordering. Preserve votes. + old_details = self.details + old_options = old_details.keys + new_details = {} + new_options = self.options + rename = {} + + 0.upto(options.length-1) do |i| + new_details[ new_options[i] ] = old_details[ old_options[i] ] + + if new_options[i] != old_options[i] + rename[ old_options[i] ] = new_options[i] + end + end + self.set_details! new_details + + # Update existing user votes. + # Accessing PluginStoreRow directly isn't a very nice approach but there's + # no way around it unfortunately. + # TODO: Probably want to move this to a background job. + PluginStoreRow.where(plugin_name: "poll", value: rename.keys).where('key LIKE ?', vote_key_prefix+"%").find_each do |row| + # This could've been done more efficiently using `update_all` instead of + # iterating over each individual vote, however this will be needed in the + # future once we support multiple choice polls. + row.value = rename[ row.value ] + row.save + end + + else + + # Options were added or removed. + new_options = self.options + new_details = self.details || {} + new_details.each do |key, value| + unless new_options.include? key + new_details.delete(key) + end + end + new_options.each do |key| + new_details[key] ||= 0 + end + self.set_details! new_details + + end + end + def details @details ||= ::PluginStore.get("poll", details_key) end @@ -71,8 +146,12 @@ module ::PollPlugin "poll_options_#{@post.id}" end + def vote_key_prefix + "poll_vote_#{@post.id}_" + end + def vote_key(user) - "poll_vote_#{@post.id}_#{user.id}" + "#{vote_key_prefix}#{user.id}" end end end diff --git a/plugins/poll/spec/poll_plugin/poll_spec.rb b/plugins/poll/spec/poll_plugin/poll_spec.rb index de4333352..ca121e379 100644 --- a/plugins/poll/spec/poll_plugin/poll_spec.rb +++ b/plugins/poll/spec/poll_plugin/poll_spec.rb @@ -63,4 +63,19 @@ describe PollPlugin::Poll do poll = PollPlugin::Poll.new(post) poll.serialize(user).should eq(nil) end + + it "stores poll options to plugin store" do + poll.set_vote!(user, "Onodera") + poll.stubs(:options).returns(["Chitoge", "Onodera", "Inferno Cop"]) + poll.update_options! + poll.details.keys.sort.should eq(["Chitoge", "Inferno Cop", "Onodera"]) + poll.details["Inferno Cop"].should eq(0) + poll.details["Onodera"].should eq(1) + + poll.stubs(:options).returns(["Chitoge", "Onodera v2", "Inferno Cop"]) + poll.update_options! + poll.details.keys.sort.should eq(["Chitoge", "Inferno Cop", "Onodera v2"]) + poll.details["Onodera v2"].should eq(1) + poll.get_vote(user).should eq("Onodera v2") + end end diff --git a/plugins/poll/spec/post_creator_spec.rb b/plugins/poll/spec/post_creator_spec.rb index 09db32ce2..7e2c78d2a 100644 --- a/plugins/poll/spec/post_creator_spec.rb +++ b/plugins/poll/spec/post_creator_spec.rb @@ -3,22 +3,34 @@ require 'post_creator' describe PostCreator do let(:user) { Fabricate(:user) } + let(:admin) { Fabricate(:admin) } context "poll topic" do + let(:poll_post) { PostCreator.create(user, {title: "Poll: This is a poll", raw: "[poll]\n* option 1\n* option 2\n* option 3\n* option 4\n[/poll]"}) } + it "cannot be created without a list of options" do post = PostCreator.create(user, {title: "Poll: This is a poll", raw: "body does not contain a list"}) post.errors[:raw].should be_present end it "cannot have options changed after 5 minutes" do - post = PostCreator.create(user, {title: "Poll: This is a poll", raw: "[poll]\n* option 1\n* option 2\n* option 3\n* option 4\n[/poll]"}) - post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n[/poll]" - post.valid?.should be_true - post.save + poll_post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n[/poll]" + poll_post.valid?.should be_true + poll_post.save Timecop.freeze(Time.now + 6.minutes) do - post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n* option 4\n[/poll]" - post.valid?.should be_false - post.errors[:raw].should be_present + poll_post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n* option 4\n[/poll]" + poll_post.valid?.should be_false + poll_post.errors[:poll_options].should be_present + end + end + + it "allows staff to edit options after 5 minutes" do + poll_post.last_editor_id = admin.id + Timecop.freeze(Time.now + 6.minutes) do + poll_post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n* option 4.1\n[/poll]" + poll_post.valid?.should be_true + poll_post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n[/poll]" + poll_post.valid?.should be_false end end end