discourse/plugins/poll/plugin.rb
2016-03-21 12:12:25 +01:00

388 lines
13 KiB
Ruby

# name: poll
# about: Official poll plugin for Discourse
# version: 0.9
# authors: Vikhyat Korrapati (vikhyat), Régis Hanol (zogstrip)
# url: https://github.com/discourse/discourse/tree/master/plugins/poll
enabled_site_setting :poll_enabled
register_asset "stylesheets/common/poll.scss"
register_asset "stylesheets/desktop/poll.scss", :desktop
register_asset "stylesheets/mobile/poll.scss", :mobile
register_asset "javascripts/poll_dialect.js", :server_side
PLUGIN_NAME ||= "discourse_poll".freeze
POLLS_CUSTOM_FIELD ||= "polls".freeze
VOTES_CUSTOM_FIELD ||= "polls-votes".freeze
DATA_PREFIX ||= "data-poll-".freeze
DEFAULT_POLL_NAME ||= "poll".freeze
after_initialize do
# turn polls into a link in emails
Email::Styles.register_plugin_style do |fragment, opts|
post = Post.find_by(id: opts[:post_id]) rescue nil
if post.nil? || post.trashed?
fragment.css(".poll").each(&:remove)
else
post_url = "#{Discourse.base_url}#{post.url}"
fragment.css(".poll").each do |poll|
poll.replace "<p><a href='#{post_url}'>#{I18n.t("poll.email.link_to_poll")}</a></p>"
end
end
end
module ::DiscoursePoll
class Engine < ::Rails::Engine
engine_name PLUGIN_NAME
isolate_namespace DiscoursePoll
end
end
class DiscoursePoll::Poll
class << self
def vote(post_id, poll_name, options, user_id)
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do
post = Post.find_by(id: post_id)
# post must not be deleted
if post.nil? || post.trashed?
raise StandardError.new I18n.t("poll.post_is_deleted")
end
# topic must not be archived
if post.topic.try(:archived)
raise StandardError.new I18n.t("poll.topic_must_be_open_to_vote")
end
polls = post.custom_fields[POLLS_CUSTOM_FIELD]
raise StandardError.new I18n.t("poll.no_polls_associated_with_this_post") if polls.blank?
poll = polls[poll_name]
raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if poll.blank?
raise StandardError.new I18n.t("poll.poll_must_be_open_to_vote") if poll["status"] != "open"
# remove options that aren't available in the poll
available_options = poll["options"].map { |o| o["id"] }.to_set
options.select! { |o| available_options.include?(o) }
raise StandardError.new I18n.t("poll.requires_at_least_1_valid_option") if options.empty?
poll["voters"] = 0
all_options = Hash.new(0)
post.custom_fields[VOTES_CUSTOM_FIELD] ||= {}
post.custom_fields[VOTES_CUSTOM_FIELD]["#{user_id}"] ||= {}
post.custom_fields[VOTES_CUSTOM_FIELD]["#{user_id}"][poll_name] = options
post.custom_fields[VOTES_CUSTOM_FIELD].each do |_, user_votes|
next unless votes = user_votes[poll_name]
votes.each { |option| all_options[option] += 1 }
poll["voters"] += 1 if (available_options & votes.to_set).size > 0
end
poll["options"].each { |o| o["votes"] = all_options[o["id"]] }
post.custom_fields[POLLS_CUSTOM_FIELD] = polls
post.save_custom_fields(true)
MessageBus.publish("/polls/#{post.topic_id}", { post_id: post_id, polls: polls })
return [poll, options]
end
end
def toggle_status(post_id, poll_name, status, user_id)
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do
post = Post.find_by(id: post_id)
# post must not be deleted
if post.nil? || post.trashed?
raise StandardError.new I18n.t("poll.post_is_deleted")
end
# topic must not be archived
if post.topic.try(:archived)
raise StandardError.new I18n.t("poll.topic_must_be_open_to_toggle_status")
end
user = User.find_by(id: user_id)
# either staff member or OP
unless user_id == post.user_id || user.try(:staff?)
raise StandardError.new I18n.t("poll.only_staff_or_op_can_toggle_status")
end
polls = post.custom_fields[POLLS_CUSTOM_FIELD]
raise StandardError.new I18n.t("poll.no_polls_associated_with_this_post") if polls.blank?
raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if polls[poll_name].blank?
polls[poll_name]["status"] = status
post.save_custom_fields(true)
MessageBus.publish("/polls/#{post.topic_id}", {post_id: post.id, polls: polls })
polls[poll_name]
end
end
def extract(raw, topic_id)
# TODO: we should fix the callback mess so that the cooked version is available
# in the validators instead of cooking twice
cooked = PrettyText.cook(raw, topic_id: topic_id)
parsed = Nokogiri::HTML(cooked)
extracted_polls = []
# extract polls
parsed.css("div.poll").each do |p|
poll = { "options" => [], "voters" => 0 }
# extract attributes
p.attributes.values.each do |attribute|
if attribute.name.start_with?(DATA_PREFIX)
poll[attribute.name[DATA_PREFIX.length..-1]] = attribute.value
end
end
# extract options
p.css("li[#{DATA_PREFIX}option-id]").each do |o|
option_id = o.attributes[DATA_PREFIX + "option-id"].value
poll["options"] << { "id" => option_id, "html" => o.inner_html, "votes" => 0 }
end
# add the poll
extracted_polls << poll
end
extracted_polls
end
end
end
require_dependency "application_controller"
class DiscoursePoll::PollsController < ::ApplicationController
requires_plugin PLUGIN_NAME
before_filter :ensure_logged_in
def vote
post_id = params.require(:post_id)
poll_name = params.require(:poll_name)
options = params.require(:options)
user_id = current_user.id
begin
poll, options = DiscoursePoll::Poll.vote(post_id, poll_name, options, user_id)
render json: { poll: poll, vote: options }
rescue StandardError => e
render_json_error e.message
end
end
def toggle_status
post_id = params.require(:post_id)
poll_name = params.require(:poll_name)
status = params.require(:status)
user_id = current_user.id
begin
poll = DiscoursePoll::Poll.toggle_status(post_id, poll_name, status, user_id)
render json: { poll: poll }
rescue StandardError => e
render_json_error e.message
end
end
end
DiscoursePoll::Engine.routes.draw do
put "/vote" => "polls#vote"
put "/toggle_status" => "polls#toggle_status"
end
Discourse::Application.routes.append do
mount ::DiscoursePoll::Engine, at: "/polls"
end
Post.class_eval do
attr_accessor :polls
after_save do
next if self.polls.blank? || !self.polls.is_a?(Hash)
post = self
polls = self.polls
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post.id}") do
post.custom_fields[POLLS_CUSTOM_FIELD] = polls
post.save_custom_fields(true)
end
end
end
validate(:post, :validate_polls) do
# only care when raw has changed!
return unless self.raw_changed?
polls = {}
extracted_polls = DiscoursePoll::Poll::extract(self.raw, self.topic_id)
extracted_polls.each do |poll|
# polls should have a unique name
if polls.has_key?(poll["name"])
poll["name"] == DEFAULT_POLL_NAME ?
self.errors.add(:base, I18n.t("poll.multiple_polls_without_name")) :
self.errors.add(:base, I18n.t("poll.multiple_polls_with_same_name", name: poll["name"]))
return
end
# options must be unique
if poll["options"].map { |o| o["id"] }.uniq.size != poll["options"].size
poll["name"] == DEFAULT_POLL_NAME ?
self.errors.add(:base, I18n.t("poll.default_poll_must_have_different_options")) :
self.errors.add(:base, I18n.t("poll.named_poll_must_have_different_options", name: poll["name"]))
return
end
# at least 2 options
if poll["options"].size < 2
poll["name"] == DEFAULT_POLL_NAME ?
self.errors.add(:base, I18n.t("poll.default_poll_must_have_at_least_2_options")) :
self.errors.add(:base, I18n.t("poll.named_poll_must_have_at_least_2_options", name: poll["name"]))
return
end
# maximum # of options
if poll["options"].size > SiteSetting.poll_maximum_options
poll["name"] == DEFAULT_POLL_NAME ?
self.errors.add(:base, I18n.t("poll.default_poll_must_have_less_options", count: SiteSetting.poll_maximum_options)) :
self.errors.add(:base, I18n.t("poll.named_poll_must_have_less_options", name: poll["name"], count: SiteSetting.poll_maximum_options))
return
end
# poll with multiple choices
if poll["type"] == "multiple"
min = (poll["min"].presence || 1).to_i
max = (poll["max"].presence || poll["options"].size).to_i
if min > max || max <= 0 || max > poll["options"].size || min >= poll["options"].size
poll["name"] == DEFAULT_POLL_NAME ?
self.errors.add(:base, I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters")) :
self.errors.add(:base, I18n.t("poll.named_poll_with_multiple_choices_has_invalid_parameters", name: poll["name"]))
return
end
end
# store the valid poll
polls[poll["name"]] = poll
end
# are we updating a post?
if self.id.present?
post = self
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post.id}") do
# load previous polls
previous_polls = post.custom_fields[POLLS_CUSTOM_FIELD] || {}
# extract options
current_options = polls.values.map { |p| p["options"].map { |o| o["id"] } }.flatten.sort
previous_options = previous_polls.values.map { |p| p["options"].map { |o| o["id"] } }.flatten.sort
# are the polls different?
if polls.keys != previous_polls.keys || current_options != previous_options
has_votes = previous_polls.keys.map { |p| previous_polls[p]["voters"].to_i }.sum > 0
# outside of the 5-minute edit window?
if post.created_at < 5.minutes.ago && has_votes
# cannot add/remove/rename polls
if polls.keys.sort != previous_polls.keys.sort
post.errors.add(:base, I18n.t("poll.cannot_change_polls_after_5_minutes"))
return
end
# deal with option changes
if User.staff.pluck(:id).include?(post.last_editor_id)
# staff can only edit options
polls.each_key do |poll_name|
if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size && previous_polls[poll_name]["voters"].to_i > 0
post.errors.add(:base, I18n.t("poll.staff_cannot_add_or_remove_options_after_5_minutes"))
return
end
end
else
# OP cannot edit poll options
post.errors.add(:base, I18n.t("poll.op_cannot_edit_options_after_5_minutes"))
return
end
end
# try to merge votes
polls.each_key do |poll_name|
next unless previous_polls.has_key?(poll_name)
# when the # of options has changed, reset all the votes
if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size
PostCustomField.where(post_id: post.id, name: VOTES_CUSTOM_FIELD).destroy_all
post.clear_custom_fields
next
end
polls[poll_name]["voters"] = previous_polls[poll_name]["voters"]
for o in 0...polls[poll_name]["options"].size
polls[poll_name]["options"][o]["votes"] = previous_polls[poll_name]["options"][o]["votes"]
end
end
# immediately store the polls
post.custom_fields[POLLS_CUSTOM_FIELD] = polls
post.save_custom_fields(true)
# publish the changes
MessageBus.publish("/polls/#{post.topic_id}", { post_id: post.id, polls: polls })
end
end
else
self.polls = polls
end
true
end
Post.register_custom_field_type(POLLS_CUSTOM_FIELD, :json)
Post.register_custom_field_type(VOTES_CUSTOM_FIELD, :json)
TopicView.add_post_custom_fields_whitelister do |user|
user ? [POLLS_CUSTOM_FIELD, VOTES_CUSTOM_FIELD] : [POLLS_CUSTOM_FIELD]
end
# tells the front-end we have a poll for that post
on(:post_created) do |post|
next if post.is_first_post? || post.custom_fields[POLLS_CUSTOM_FIELD].blank?
MessageBus.publish("/polls/#{post.topic_id}", {
post_id: post.id,
polls: post.custom_fields[POLLS_CUSTOM_FIELD]})
end
add_to_serializer(:post, :polls, false) { post_custom_fields[POLLS_CUSTOM_FIELD] }
add_to_serializer(:post, :include_polls?) { post_custom_fields.present? && post_custom_fields[POLLS_CUSTOM_FIELD].present? }
add_to_serializer(:post, :polls_votes, false) { post_custom_fields[VOTES_CUSTOM_FIELD]["#{scope.user.id}"] }
add_to_serializer(:post, :include_polls_votes?) do
return unless scope.user
return unless post_custom_fields.present?
return unless post_custom_fields[VOTES_CUSTOM_FIELD].present?
post_custom_fields[VOTES_CUSTOM_FIELD].has_key?("#{scope.user.id}")
end
end