mirror of
https://github.com/discourse/discourse.git
synced 2024-12-01 12:03:43 +08:00
babbebfb35
Adds an optional title attribute to polls. The rationale for this addition is that polls themselves didn't contain context/question and relied on post body to explain them. That context wasn't always obvious (e.g. when there are multiple polls in a single post) or available (e.g. when you display the poll breakdown - you see the answers, but not the question) As a side note, here's a word on how the poll plugin works: > We have a markdown poll renderer, which we use in the builder UI and the composer preview, but… when you submit a post, raw markdown is cooked into html (twice), then we extract data from the generated html and save it to the database. When it's render time, we first display the cooked html poll, and then extract some data from that html, get the data from the post's JSON (and identify that poll using the extracted html stuff) to then render the poll using widgets and the JSON data.
636 lines
20 KiB
Ruby
636 lines
20 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# name: poll
|
|
# about: Official poll plugin for Discourse
|
|
# version: 1.0
|
|
# authors: Vikhyat Korrapati (vikhyat), Régis Hanol (zogstrip)
|
|
# url: https://github.com/discourse/discourse/tree/master/plugins/poll
|
|
|
|
register_asset "stylesheets/common/poll.scss"
|
|
register_asset "stylesheets/common/poll-ui-builder.scss"
|
|
register_asset "stylesheets/common/poll-breakdown.scss"
|
|
register_asset "stylesheets/desktop/poll.scss", :desktop
|
|
register_asset "stylesheets/mobile/poll.scss", :mobile
|
|
register_asset "stylesheets/mobile/poll-ui-builder.scss", :mobile
|
|
|
|
register_svg_icon "far fa-check-square"
|
|
|
|
enabled_site_setting :poll_enabled
|
|
hide_plugin if self.respond_to?(:hide_plugin)
|
|
|
|
PLUGIN_NAME ||= "discourse_poll"
|
|
DATA_PREFIX ||= "data-poll-"
|
|
|
|
after_initialize do
|
|
|
|
[
|
|
"../app/models/poll_vote",
|
|
"../app/models/poll_option",
|
|
"../app/models/poll",
|
|
"../app/serializers/poll_option_serializer",
|
|
"../app/serializers/poll_serializer",
|
|
"../lib/polls_validator",
|
|
"../lib/polls_updater",
|
|
"../lib/post_validator",
|
|
"../jobs/regular/close_poll",
|
|
].each { |path| require File.expand_path(path, __FILE__) }
|
|
|
|
module ::DiscoursePoll
|
|
HAS_POLLS ||= "has_polls"
|
|
DEFAULT_POLL_NAME ||= "poll"
|
|
|
|
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)
|
|
Poll.transaction 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&.archived
|
|
raise StandardError.new I18n.t("poll.topic_must_be_open_to_vote")
|
|
end
|
|
|
|
# user must be allowed to post in topic
|
|
guardian = Guardian.new(user)
|
|
if !guardian.can_create_post?(post.topic)
|
|
raise StandardError.new I18n.t("poll.user_cant_post_in_topic")
|
|
end
|
|
|
|
poll = Poll.includes(:poll_options).find_by(post_id: post_id, name: poll_name)
|
|
|
|
raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) unless poll
|
|
raise StandardError.new I18n.t("poll.poll_must_be_open_to_vote") if poll.is_closed?
|
|
|
|
if poll.groups
|
|
poll_groups = poll.groups.split(",").map(&:downcase)
|
|
user_groups = user.groups.map { |g| g.name.downcase }
|
|
if (poll_groups & user_groups).empty?
|
|
raise StandardError.new I18n.t("js.poll.results.groups.title", groups: poll.groups)
|
|
end
|
|
end
|
|
|
|
# remove options that aren't available in the poll
|
|
available_options = poll.poll_options.map { |o| o.digest }.to_set
|
|
options.select! { |o| available_options.include?(o) }
|
|
|
|
raise StandardError.new I18n.t("poll.requires_at_least_1_valid_option") if options.empty?
|
|
|
|
new_option_ids = poll.poll_options.each_with_object([]) do |option, obj|
|
|
obj << option.id if options.include?(option.digest)
|
|
end
|
|
|
|
old_option_ids = poll.poll_options.each_with_object([]) do |option, obj|
|
|
if option.poll_votes.where(user_id: user.id).exists?
|
|
obj << option.id
|
|
end
|
|
end
|
|
|
|
# remove non-selected votes
|
|
PollVote
|
|
.where(poll: poll, user: user)
|
|
.where.not(poll_option_id: new_option_ids)
|
|
.delete_all
|
|
|
|
# create missing votes
|
|
(new_option_ids - old_option_ids).each do |option_id|
|
|
PollVote.create!(poll: poll, user: user, poll_option_id: option_id)
|
|
end
|
|
|
|
poll.reload
|
|
|
|
serialized_poll = PollSerializer.new(poll, root: false, scope: guardian).as_json
|
|
payload = { post_id: post_id, polls: [serialized_poll] }
|
|
|
|
post.publish_message!("/polls/#{post.topic_id}", payload)
|
|
|
|
[serialized_poll, options]
|
|
end
|
|
end
|
|
|
|
def toggle_status(post_id, poll_name, status, user, raise_errors = true)
|
|
Poll.transaction do
|
|
post = Post.find_by(id: post_id)
|
|
guardian = Guardian.new(user)
|
|
|
|
# post must not be deleted
|
|
if post.nil? || post.trashed?
|
|
raise StandardError.new I18n.t("poll.post_is_deleted") if raise_errors
|
|
return
|
|
end
|
|
|
|
# topic must not be archived
|
|
if post.topic&.archived
|
|
raise StandardError.new I18n.t("poll.topic_must_be_open_to_toggle_status") if raise_errors
|
|
return
|
|
end
|
|
|
|
# either staff member or OP
|
|
unless post.user_id == user&.id || user&.staff?
|
|
raise StandardError.new I18n.t("poll.only_staff_or_op_can_toggle_status") if raise_errors
|
|
return
|
|
end
|
|
|
|
poll = Poll.find_by(post_id: post_id, name: poll_name)
|
|
|
|
if !poll
|
|
raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if raise_errors
|
|
return
|
|
end
|
|
|
|
poll.status = status
|
|
poll.save!
|
|
|
|
serialized_poll = PollSerializer.new(poll, root: false, scope: guardian).as_json
|
|
payload = { post_id: post_id, polls: [serialized_poll] }
|
|
|
|
post.publish_message!("/polls/#{post.topic_id}", payload)
|
|
|
|
serialized_poll
|
|
end
|
|
end
|
|
|
|
def serialized_voters(poll, opts = {})
|
|
limit = (opts["limit"] || 25).to_i
|
|
limit = 0 if limit < 0
|
|
limit = 50 if limit > 50
|
|
|
|
page = (opts["page"] || 1).to_i
|
|
page = 1 if page < 1
|
|
|
|
offset = (page - 1) * limit
|
|
|
|
option_digest = opts["option_id"].to_s
|
|
|
|
if poll.number?
|
|
user_ids = PollVote
|
|
.where(poll: poll)
|
|
.group(:user_id)
|
|
.order("MIN(created_at)")
|
|
.offset(offset)
|
|
.limit(limit)
|
|
.pluck(:user_id)
|
|
|
|
result = User.where(id: user_ids).map { |u| UserNameSerializer.new(u).serializable_hash }
|
|
elsif option_digest.present?
|
|
poll_option = PollOption.find_by(poll: poll, digest: option_digest)
|
|
|
|
raise Discourse::InvalidParameters.new(:option_id) unless poll_option
|
|
|
|
user_ids = PollVote
|
|
.where(poll: poll, poll_option: poll_option)
|
|
.group(:user_id)
|
|
.order("MIN(created_at)")
|
|
.offset(offset)
|
|
.limit(limit)
|
|
.pluck(:user_id)
|
|
|
|
user_hashes = User.where(id: user_ids).map { |u| UserNameSerializer.new(u).serializable_hash }
|
|
|
|
result = { option_digest => user_hashes }
|
|
else
|
|
votes = DB.query <<~SQL
|
|
SELECT digest, user_id
|
|
FROM (
|
|
SELECT digest
|
|
, user_id
|
|
, ROW_NUMBER() OVER (PARTITION BY poll_option_id ORDER BY pv.created_at) AS row
|
|
FROM poll_votes pv
|
|
JOIN poll_options po ON pv.poll_option_id = po.id
|
|
WHERE pv.poll_id = #{poll.id}
|
|
AND po.poll_id = #{poll.id}
|
|
) v
|
|
WHERE row BETWEEN #{offset} AND #{offset + limit}
|
|
SQL
|
|
|
|
user_ids = votes.map(&:user_id).uniq
|
|
|
|
user_hashes = User
|
|
.where(id: user_ids)
|
|
.map { |u| [u.id, UserNameSerializer.new(u).serializable_hash] }
|
|
.to_h
|
|
|
|
result = {}
|
|
votes.each do |v|
|
|
result[v.digest] ||= []
|
|
result[v.digest] << user_hashes[v.user_id]
|
|
end
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
def voters(post_id, poll_name, user, opts = {})
|
|
post = Post.find_by(id: post_id)
|
|
raise Discourse::InvalidParameters.new(:post_id) unless post
|
|
|
|
poll = Poll.find_by(post_id: post_id, name: poll_name)
|
|
raise Discourse::InvalidParameters.new(:poll_name) unless poll&.can_see_voters?(user)
|
|
|
|
serialized_voters(poll, opts)
|
|
end
|
|
|
|
def transform_for_user_field_override(custom_user_field)
|
|
existing_field = UserField.find_by(name: custom_user_field)
|
|
existing_field ? "user_field_#{existing_field.id}" : custom_user_field
|
|
end
|
|
|
|
def grouped_poll_results(post_id, poll_name, user_field_name, user)
|
|
post = Post.find_by(id: post_id)
|
|
raise Discourse::InvalidParameters.new(:post_id) unless post
|
|
|
|
poll = Poll.includes(:poll_options).includes(:poll_votes).find_by(post_id: post_id, name: poll_name)
|
|
raise Discourse::InvalidParameters.new(:poll_name) unless poll
|
|
|
|
raise Discourse::InvalidParameters.new(:user_field_name) unless SiteSetting.poll_groupable_user_fields.split('|').include?(user_field_name)
|
|
|
|
poll_votes = poll.poll_votes
|
|
|
|
poll_options = {}
|
|
poll.poll_options.each do |option|
|
|
poll_options[option.id.to_s] = { html: option.html, digest: option.digest }
|
|
end
|
|
|
|
user_ids = poll_votes.map(&:user_id).uniq
|
|
user_fields = UserCustomField.where(user_id: user_ids, name: transform_for_user_field_override(user_field_name))
|
|
|
|
user_field_map = {}
|
|
user_fields.each do |f|
|
|
# Build hash, so we can quickly look up field values for each user.
|
|
user_field_map[f.user_id] = f.value
|
|
end
|
|
|
|
votes_with_field = poll_votes.map do |vote|
|
|
v = vote.attributes
|
|
v[:field_value] = user_field_map[vote.user_id]
|
|
v
|
|
end
|
|
|
|
chart_data = []
|
|
votes_with_field.group_by { |vote| vote[:field_value] }.each do |field_answer, votes|
|
|
grouped_selected_options = {}
|
|
|
|
# Create all the options with 0 votes. This ensures all the charts will have the same order of options, and same colors per option.
|
|
poll_options.each do |id, option|
|
|
grouped_selected_options[id] = {
|
|
digest: option[:digest],
|
|
html: option[:html],
|
|
votes: 0
|
|
}
|
|
end
|
|
|
|
# Now go back and update the vote counts. Using hashes so we dont have n^2
|
|
votes.group_by { |v| v["poll_option_id"] }.each do |option_id, votes_for_option|
|
|
grouped_selected_options[option_id.to_s][:votes] = votes_for_option.length
|
|
end
|
|
|
|
group_label = field_answer ? field_answer.titleize : I18n.t("poll.user_field.no_data")
|
|
chart_data << { group: group_label, options: grouped_selected_options.values }
|
|
end
|
|
chart_data
|
|
end
|
|
|
|
def schedule_jobs(post)
|
|
Poll.where(post: post).find_each do |poll|
|
|
job_args = {
|
|
post_id: post.id,
|
|
poll_name: poll.name
|
|
}
|
|
|
|
Jobs.cancel_scheduled_job(:close_poll, job_args)
|
|
|
|
if poll.open? && poll.close_at && poll.close_at > Time.zone.now
|
|
Jobs.enqueue_at(poll.close_at, :close_poll, job_args)
|
|
end
|
|
end
|
|
end
|
|
|
|
def create!(post_id, poll)
|
|
close_at = begin
|
|
Time.zone.parse(poll["close"] || '')
|
|
rescue ArgumentError
|
|
end
|
|
|
|
created_poll = Poll.create!(
|
|
post_id: post_id,
|
|
name: poll["name"].presence || "poll",
|
|
close_at: close_at,
|
|
type: poll["type"].presence || "regular",
|
|
status: poll["status"].presence || "open",
|
|
visibility: poll["public"] == "true" ? "everyone" : "secret",
|
|
title: poll["title"],
|
|
results: poll["results"].presence || "always",
|
|
min: poll["min"],
|
|
max: poll["max"],
|
|
step: poll["step"],
|
|
chart_type: poll["charttype"] || "bar",
|
|
groups: poll["groups"]
|
|
)
|
|
|
|
poll["options"].each do |option|
|
|
PollOption.create!(
|
|
poll: created_poll,
|
|
digest: option["id"].presence,
|
|
html: option["html"].presence&.strip
|
|
)
|
|
end
|
|
end
|
|
|
|
def extract(raw, topic_id, user_id = nil)
|
|
# 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, user_id: user_id)
|
|
|
|
Nokogiri::HTML5(cooked).css("div.poll").map do |p|
|
|
poll = { "options" => [], "name" => DiscoursePoll::DEFAULT_POLL_NAME }
|
|
|
|
# attributes
|
|
p.attributes.values.each do |attribute|
|
|
if attribute.name.start_with?(DATA_PREFIX)
|
|
poll[attribute.name[DATA_PREFIX.length..-1]] = CGI.escapeHTML(attribute.value || "")
|
|
end
|
|
end
|
|
|
|
# options
|
|
p.css("li[#{DATA_PREFIX}option-id]").each do |o|
|
|
option_id = o.attributes[DATA_PREFIX + "option-id"].value.to_s
|
|
poll["options"] << { "id" => option_id, "html" => o.inner_html.strip }
|
|
end
|
|
|
|
# title
|
|
title_element = p.css(".poll-title").first
|
|
if title_element
|
|
poll["title"] = title_element.inner_html.strip
|
|
end
|
|
|
|
poll
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
class DiscoursePoll::PollsController < ::ApplicationController
|
|
requires_plugin PLUGIN_NAME
|
|
|
|
before_action :ensure_logged_in, except: [:voters, :grouped_poll_results]
|
|
|
|
def vote
|
|
post_id = params.require(:post_id)
|
|
poll_name = params.require(:poll_name)
|
|
options = params.require(:options)
|
|
|
|
begin
|
|
poll, options = DiscoursePoll::Poll.vote(post_id, poll_name, options, current_user)
|
|
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)
|
|
|
|
begin
|
|
poll = DiscoursePoll::Poll.toggle_status(post_id, poll_name, status, current_user)
|
|
render json: { poll: poll }
|
|
rescue StandardError => e
|
|
render_json_error e.message
|
|
end
|
|
end
|
|
|
|
def voters
|
|
post_id = params.require(:post_id)
|
|
poll_name = params.require(:poll_name)
|
|
|
|
opts = params.permit(:limit, :page, :option_id)
|
|
|
|
begin
|
|
render json: { voters: DiscoursePoll::Poll.voters(post_id, poll_name, current_user, opts) }
|
|
rescue StandardError => e
|
|
render_json_error e.message
|
|
end
|
|
end
|
|
|
|
def grouped_poll_results
|
|
post_id = params.require(:post_id)
|
|
poll_name = params.require(:poll_name)
|
|
user_field_name = params.require(:user_field_name)
|
|
|
|
begin
|
|
render json: {
|
|
grouped_results: DiscoursePoll::Poll.grouped_poll_results(post_id, poll_name, user_field_name, current_user)
|
|
}
|
|
rescue StandardError => e
|
|
render_json_error e.message
|
|
end
|
|
end
|
|
|
|
def groupable_user_fields
|
|
render json: {
|
|
fields: SiteSetting.poll_groupable_user_fields.split('|').map do |field|
|
|
{ name: field.humanize.capitalize, value: field }
|
|
end
|
|
}
|
|
end
|
|
end
|
|
|
|
DiscoursePoll::Engine.routes.draw do
|
|
put "/vote" => "polls#vote"
|
|
put "/toggle_status" => "polls#toggle_status"
|
|
get "/voters" => 'polls#voters'
|
|
get "/grouped_poll_results" => 'polls#grouped_poll_results'
|
|
get "/groupable_user_fields" => 'polls#groupable_user_fields'
|
|
end
|
|
|
|
Discourse::Application.routes.append do
|
|
mount ::DiscoursePoll::Engine, at: "/polls"
|
|
end
|
|
|
|
reloadable_patch do
|
|
Post.class_eval do
|
|
attr_accessor :extracted_polls
|
|
|
|
has_many :polls, dependent: :destroy
|
|
|
|
after_save do
|
|
polls = self.extracted_polls
|
|
next if polls.blank? || !polls.is_a?(Hash)
|
|
post = self
|
|
|
|
Poll.transaction do
|
|
polls.values.each do |poll|
|
|
DiscoursePoll::Poll.create!(post.id, poll)
|
|
end
|
|
post.custom_fields[DiscoursePoll::HAS_POLLS] = true
|
|
post.save_custom_fields(true)
|
|
end
|
|
end
|
|
end
|
|
|
|
User.class_eval do
|
|
has_many :poll_votes, dependent: :delete_all
|
|
end
|
|
end
|
|
|
|
validate(:post, :validate_polls) do |force = nil|
|
|
return unless self.raw_changed? || force
|
|
|
|
validator = DiscoursePoll::PollsValidator.new(self)
|
|
return unless (polls = validator.validate_polls)
|
|
|
|
if polls.present?
|
|
validator = DiscoursePoll::PostValidator.new(self)
|
|
return unless validator.validate_post
|
|
end
|
|
|
|
# are we updating a post?
|
|
if self.id.present?
|
|
DiscoursePoll::PollsUpdater.update(self, polls)
|
|
else
|
|
self.extracted_polls = polls
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
allow_new_queued_post_payload_attribute("is_poll")
|
|
|
|
NewPostManager.add_handler(1) do |manager|
|
|
post = Post.new(raw: manager.args[:raw])
|
|
|
|
if !DiscoursePoll::PollsValidator.new(post).validate_polls
|
|
result = NewPostResult.new(:poll, false)
|
|
|
|
post.errors.full_messages.each do |message|
|
|
result.errors[:base] << message
|
|
end
|
|
|
|
result
|
|
else
|
|
manager.args["is_poll"] = true
|
|
nil
|
|
end
|
|
end
|
|
|
|
on(:approved_post) do |queued_post, created_post|
|
|
if queued_post.payload["is_poll"]
|
|
created_post.validate_polls(true)
|
|
end
|
|
end
|
|
|
|
on(:reduce_cooked) do |fragment, post|
|
|
if post.nil? || post.trashed?
|
|
fragment.css(".poll, [data-poll-name]").each(&:remove)
|
|
else
|
|
post_url = post.full_url
|
|
fragment.css(".poll, [data-poll-name]").each do |poll|
|
|
poll.replace "<p><a href='#{post_url}'>#{I18n.t("poll.email.link_to_poll")}</a></p>"
|
|
end
|
|
end
|
|
end
|
|
|
|
on(:reduce_excerpt) do |doc, options|
|
|
post = options[:post]
|
|
|
|
replacement = post&.url.present? ?
|
|
"<a href='#{UrlHelper.escape_uri(post.url)}'>#{I18n.t("poll.poll")}</a>" :
|
|
I18n.t("poll.poll")
|
|
|
|
doc.css("div.poll").each do |poll|
|
|
poll.replace(replacement)
|
|
end
|
|
end
|
|
|
|
on(:post_created) do |post, _opts, user|
|
|
guardian = Guardian.new(user)
|
|
DiscoursePoll::Poll.schedule_jobs(post)
|
|
|
|
unless post.is_first_post?
|
|
polls = ActiveModel::ArraySerializer.new(post.polls, each_serializer: PollSerializer, root: false, scope: guardian).as_json
|
|
post.publish_message!("/polls/#{post.topic_id}", post_id: post.id, polls: polls)
|
|
end
|
|
end
|
|
|
|
on(:merging_users) do |source_user, target_user|
|
|
PollVote.where(user_id: source_user.id).update_all(user_id: target_user.id)
|
|
end
|
|
|
|
register_post_custom_field_type(DiscoursePoll::HAS_POLLS, :boolean)
|
|
|
|
topic_view_post_custom_fields_allowlister { [DiscoursePoll::HAS_POLLS] }
|
|
|
|
add_to_class(:topic_view, :polls) do
|
|
@polls ||= begin
|
|
polls = {}
|
|
|
|
post_with_polls = @post_custom_fields.each_with_object([]) do |fields, obj|
|
|
obj << fields[0] if fields[1][DiscoursePoll::HAS_POLLS]
|
|
end
|
|
|
|
if post_with_polls.present?
|
|
Poll
|
|
.where(post_id: post_with_polls)
|
|
.each do |p|
|
|
polls[p.post_id] ||= []
|
|
polls[p.post_id] << p
|
|
end
|
|
end
|
|
|
|
polls
|
|
end
|
|
end
|
|
|
|
add_to_serializer(:post, :preloaded_polls, false) do
|
|
@preloaded_polls ||= if @topic_view.present?
|
|
@topic_view.polls[object.id]
|
|
else
|
|
Poll.includes(:poll_options).where(post: object)
|
|
end
|
|
end
|
|
|
|
add_to_serializer(:post, :include_preloaded_polls?) do
|
|
false
|
|
end
|
|
|
|
add_to_serializer(:post, :polls, false) do
|
|
preloaded_polls.map { |p| PollSerializer.new(p, root: false, scope: self.scope) }
|
|
end
|
|
|
|
add_to_serializer(:post, :include_polls?) do
|
|
SiteSetting.poll_enabled && preloaded_polls.present?
|
|
end
|
|
|
|
add_to_serializer(:post, :polls_votes, false) do
|
|
preloaded_polls.map do |poll|
|
|
user_poll_votes =
|
|
poll
|
|
.poll_votes
|
|
.where(user_id: scope.user.id)
|
|
.joins(:poll_option)
|
|
.pluck("poll_options.digest")
|
|
|
|
[poll.name, user_poll_votes]
|
|
end.to_h
|
|
end
|
|
|
|
add_to_serializer(:post, :include_polls_votes?) do
|
|
SiteSetting.poll_enabled &&
|
|
scope.user&.id.present? &&
|
|
preloaded_polls.present? &&
|
|
preloaded_polls.any? { |p| p.has_voted?(scope.user) }
|
|
end
|
|
end
|