525 lines
16 KiB
Ruby
525 lines
16 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class DiscoursePoll::Poll
|
|
RANKED_CHOICE = "ranked_choice"
|
|
MULTIPLE = "multiple"
|
|
REGULAR = "regular"
|
|
|
|
def self.vote(user, post_id, poll_name, options)
|
|
poll_id = nil
|
|
|
|
serialized_poll =
|
|
DiscoursePoll::Poll.change_vote(user, post_id, poll_name) do |poll|
|
|
poll_id = poll.id
|
|
# remove options that aren't available in the poll
|
|
available_options = poll.poll_options.map { |o| o.digest }.to_set
|
|
|
|
if poll.ranked_choice?
|
|
options = options.values.map { |hash| hash }
|
|
options.select! { |o| available_options.include?(o[:digest]) }
|
|
else
|
|
options.select! { |o| available_options.include?(o) }
|
|
end
|
|
|
|
if options.empty?
|
|
raise DiscoursePoll::Error.new I18n.t("poll.requires_at_least_1_valid_option")
|
|
end
|
|
|
|
new_option_ids =
|
|
poll
|
|
.poll_options
|
|
.each_with_object([]) do |option, obj|
|
|
if poll.ranked_choice?
|
|
obj << option.id if options.any? { |o| o[:digest] == option.digest }
|
|
else
|
|
obj << option.id if options.include?(option.digest)
|
|
end
|
|
end
|
|
|
|
self.validate_votes!(poll, new_option_ids)
|
|
|
|
old_option_ids =
|
|
poll
|
|
.poll_options
|
|
.each_with_object([]) do |option, obj|
|
|
obj << option.id if option.poll_votes.where(user_id: user.id).exists?
|
|
end
|
|
|
|
if poll.ranked_choice?
|
|
# for ranked choice, we need to remove all votes and re-create them as there is no way to update them due to lack of primary key.
|
|
PollVote.where(poll: poll, user: user).delete_all
|
|
creation_set = new_option_ids
|
|
else
|
|
# remove non-selected votes
|
|
PollVote
|
|
.where(poll: poll, user: user)
|
|
.where.not(poll_option_id: new_option_ids)
|
|
.delete_all
|
|
creation_set = new_option_ids - old_option_ids
|
|
end
|
|
|
|
# create missing votes
|
|
creation_set.each do |option_id|
|
|
if poll.ranked_choice?
|
|
option_digest = poll.poll_options.find(option_id).digest
|
|
|
|
PollVote.create!(
|
|
poll: poll,
|
|
user: user,
|
|
poll_option_id: option_id,
|
|
rank: options.find { |o| o[:digest] == option_digest }[:rank],
|
|
)
|
|
else
|
|
PollVote.create!(poll: poll, user: user, poll_option_id: option_id)
|
|
end
|
|
end
|
|
end
|
|
|
|
if serialized_poll[:type] == RANKED_CHOICE
|
|
serialized_poll[:ranked_choice_outcome] = DiscoursePoll::RankedChoice.outcome(poll_id)
|
|
else
|
|
# Ensure consistency here as we do not have a unique index to limit the
|
|
# number of votes per the poll's configuration.
|
|
is_multiple = serialized_poll[:type] == MULTIPLE
|
|
offset = is_multiple ? (serialized_poll[:max] || serialized_poll[:options].length) : 1
|
|
|
|
params = { poll_id: poll_id, offset: offset, user_id: user.id }
|
|
|
|
DB.query(<<~SQL, params)
|
|
DELETE FROM poll_votes
|
|
USING (
|
|
SELECT
|
|
poll_id,
|
|
user_id
|
|
FROM poll_votes
|
|
WHERE poll_id = :poll_id
|
|
AND user_id = :user_id
|
|
ORDER BY created_at DESC
|
|
OFFSET :offset
|
|
) to_delete_poll_votes
|
|
WHERE poll_votes.poll_id = to_delete_poll_votes.poll_id
|
|
AND poll_votes.user_id = to_delete_poll_votes.user_id
|
|
SQL
|
|
end
|
|
|
|
serialized_poll[:options].each do |option|
|
|
if serialized_poll[:type] == RANKED_CHOICE
|
|
option.merge!(
|
|
rank:
|
|
PollVote
|
|
.joins(:poll_option)
|
|
.where(poll_options: { digest: option[:id] }, user_id: user.id, poll_id: poll_id)
|
|
.limit(1)
|
|
.pluck(:rank),
|
|
)
|
|
elsif serialized_poll[:type] == MULTIPLE
|
|
option.merge!(
|
|
chosen:
|
|
PollVote
|
|
.joins(:poll_option)
|
|
.where(poll_options: { digest: option[:id] }, user_id: user.id, poll_id: poll_id)
|
|
.exists?,
|
|
)
|
|
end
|
|
end
|
|
|
|
if serialized_poll[:type] == MULTIPLE
|
|
serialized_poll[:options].each do |option|
|
|
option.merge!(
|
|
chosen:
|
|
PollVote
|
|
.joins(:poll_option)
|
|
.where(poll_options: { digest: option[:id] }, user_id: user.id, poll_id: poll_id)
|
|
.exists?,
|
|
)
|
|
end
|
|
end
|
|
|
|
[serialized_poll, options]
|
|
end
|
|
|
|
def self.remove_vote(user, post_id, poll_name)
|
|
poll_id = nil
|
|
|
|
serialized_poll =
|
|
DiscoursePoll::Poll.change_vote(user, post_id, poll_name) do |poll|
|
|
poll_id = poll.id
|
|
PollVote.where(poll: poll, user: user).delete_all
|
|
end
|
|
|
|
if serialized_poll[:type] == RANKED_CHOICE
|
|
serialized_poll[:ranked_choice_outcome] = DiscoursePoll::RankedChoice.outcome(poll_id)
|
|
end
|
|
|
|
serialized_poll
|
|
end
|
|
|
|
def self.toggle_status(user, post_id, poll_name, status, 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 DiscoursePoll::Error.new I18n.t("poll.post_is_deleted") if raise_errors
|
|
return
|
|
end
|
|
|
|
# topic must not be archived
|
|
if post.topic&.archived
|
|
if raise_errors
|
|
raise DiscoursePoll::Error.new I18n.t("poll.topic_must_be_open_to_toggle_status")
|
|
end
|
|
return
|
|
end
|
|
|
|
# either staff member or OP
|
|
unless post.user_id == user&.id || user&.staff?
|
|
if raise_errors
|
|
raise DiscoursePoll::Error.new I18n.t("poll.only_staff_or_op_can_toggle_status")
|
|
end
|
|
return
|
|
end
|
|
|
|
poll = Poll.find_by(post_id: post_id, name: poll_name)
|
|
|
|
if !poll
|
|
if raise_errors
|
|
raise DiscoursePoll::Error.new I18n.t("poll.no_poll_with_this_name", name: poll_name)
|
|
end
|
|
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 self.serialized_voters(poll, opts = {})
|
|
preload_serialized_voters!([poll], opts)[poll.id]
|
|
end
|
|
|
|
def self.preload_serialized_voters!(polls, opts = {})
|
|
# This method is used in order to avoid N+1s and preloads serialized voters
|
|
# for multiple polls from a topic view. After the first call, the serialized
|
|
# voters are cached in the Poll object and returned from there for future
|
|
# calls.
|
|
|
|
page = [1, (opts["page"] || 1).to_i].max
|
|
limit = (opts["limit"] || 25).to_i.clamp(1, 50)
|
|
offset = (page - 1) * limit
|
|
|
|
params = {
|
|
offset: offset,
|
|
offset_plus_limit: offset + limit,
|
|
option_digest: opts[:option_id].presence,
|
|
}
|
|
|
|
result = {}
|
|
|
|
uncached_poll_ids = []
|
|
polls.each do |p|
|
|
if p.serialized_voters_cache&.key?(params)
|
|
result[p.id] = p.serialized_voters_cache[params]
|
|
else
|
|
uncached_poll_ids << p.id
|
|
end
|
|
end
|
|
|
|
return result if uncached_poll_ids.empty?
|
|
|
|
where_clause = params[:option_digest] ? "AND po.digest = :option_digest" : ""
|
|
query = <<~SQL.gsub("/* where */", where_clause)
|
|
SELECT poll_id, digest, rank, user_id
|
|
FROM (
|
|
SELECT pv.poll_id
|
|
, po.digest
|
|
, CASE pv.rank WHEN 0 THEN 'Abstain' ELSE CAST(pv.rank AS text) END AS rank
|
|
, pv.user_id
|
|
, u.username
|
|
, ROW_NUMBER() OVER (PARTITION BY pv.poll_option_id ORDER BY pv.created_at) AS row
|
|
FROM poll_votes pv
|
|
JOIN poll_options po ON pv.poll_id = po.poll_id AND pv.poll_option_id = po.id
|
|
JOIN users u ON pv.user_id = u.id
|
|
WHERE pv.poll_id IN (:poll_ids)
|
|
/* where */
|
|
) v
|
|
WHERE row BETWEEN :offset AND :offset_plus_limit
|
|
ORDER BY digest, CASE WHEN rank = 'Abstain' THEN 1 ELSE CAST(rank AS integer) END, username
|
|
SQL
|
|
|
|
votes = DB.query(query, params.merge(poll_ids: uncached_poll_ids))
|
|
|
|
users =
|
|
User
|
|
.where(id: votes.map(&:user_id).uniq)
|
|
.map { |u| [u.id, UserNameSerializer.new(u).serializable_hash] }
|
|
.to_h
|
|
|
|
polls_by_id = polls.index_by(&:id)
|
|
votes.each do |v|
|
|
if polls_by_id[v.poll_id].number?
|
|
result[v.poll_id] ||= []
|
|
result[v.poll_id] << users[v.user_id]
|
|
elsif polls_by_id[v.poll_id].ranked_choice?
|
|
result[v.poll_id] ||= Hash.new { |h, k| h[k] = [] }
|
|
result[v.poll_id][v.digest] << { rank: v.rank, user: users[v.user_id] }
|
|
else
|
|
result[v.poll_id] ||= Hash.new { |h, k| h[k] = [] }
|
|
result[v.poll_id][v.digest] << users[v.user_id]
|
|
end
|
|
end
|
|
|
|
polls.each do |p|
|
|
p.serialized_voters_cache ||= {}
|
|
p.serialized_voters_cache[params] = result[p.id]
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
def self.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 self.grouped_poll_results(user, post_id, poll_name, user_field_name)
|
|
raise Discourse::InvalidParameters.new(:post_id) if !Post.where(id: post_id).exists?
|
|
poll =
|
|
Poll.includes(:poll_options, :poll_votes, post: :topic).find_by(
|
|
post_id: post_id,
|
|
name: poll_name,
|
|
)
|
|
raise Discourse::InvalidParameters.new(:poll_name) unless poll
|
|
|
|
# user must be allowed to post in topic
|
|
guardian = Guardian.new(user)
|
|
if !guardian.can_create_post?(poll.post.topic)
|
|
raise DiscoursePoll::Error.new I18n.t("poll.user_cant_post_in_topic")
|
|
end
|
|
|
|
if SiteSetting.poll_groupable_user_fields.split("|").exclude?(user_field_name)
|
|
raise Discourse::InvalidParameters.new(:user_field_name)
|
|
end
|
|
|
|
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 self.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 self.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 self.extract(raw, topic_id, user_id = nil)
|
|
# Poll Post handlers get called very early in the post
|
|
# creation process. `raw` could be nil here.
|
|
return [] if raw.blank?
|
|
|
|
# bail-out early if the post does not contain a poll
|
|
return [] if !raw.include?("[/poll]")
|
|
|
|
# TODO: we should fix the callback mess so that the cooked version is available
|
|
# in the validators instead of cooking twice
|
|
raw = raw.sub(%r{\[quote.+/quote\]}m, "")
|
|
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?(DiscoursePoll::DATA_PREFIX)
|
|
poll[attribute.name[DiscoursePoll::DATA_PREFIX.length..-1]] = CGI.escapeHTML(
|
|
attribute.value || "",
|
|
)
|
|
end
|
|
end
|
|
|
|
# options
|
|
p
|
|
.css("li[#{DiscoursePoll::DATA_PREFIX}option-id]")
|
|
.each do |o|
|
|
option_id = o.attributes[DiscoursePoll::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
|
|
poll["title"] = title_element.inner_html.strip if title_element
|
|
|
|
poll
|
|
end
|
|
end
|
|
|
|
def self.validate_votes!(poll, options)
|
|
num_of_options = options.length
|
|
|
|
if poll.multiple?
|
|
if poll.min && (num_of_options < poll.min)
|
|
raise DiscoursePoll::Error.new(I18n.t("poll.min_vote_per_user", count: poll.min))
|
|
elsif poll.max && (num_of_options > poll.max)
|
|
raise DiscoursePoll::Error.new(I18n.t("poll.max_vote_per_user", count: poll.max))
|
|
end
|
|
elsif poll.ranked_choice?
|
|
if poll.poll_options.length != num_of_options
|
|
raise DiscoursePoll::Error.new(
|
|
I18n.t(
|
|
"poll.ranked_choice.vote_options_mismatch",
|
|
count: poll.options.length,
|
|
provided: num_of_options,
|
|
),
|
|
)
|
|
end
|
|
elsif num_of_options > 1
|
|
raise DiscoursePoll::Error.new(I18n.t("poll.one_vote_per_user"))
|
|
end
|
|
end
|
|
private_class_method :validate_votes!
|
|
|
|
def self.change_vote(user, post_id, poll_name)
|
|
Poll.transaction do
|
|
post = Post.find_by(id: post_id)
|
|
|
|
# post must not be deleted
|
|
raise DiscoursePoll::Error.new I18n.t("poll.post_is_deleted") if post.nil? || post.trashed?
|
|
|
|
# topic must not be archived
|
|
if post.topic&.archived
|
|
raise DiscoursePoll::Error.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 DiscoursePoll::Error.new I18n.t("poll.user_cant_post_in_topic")
|
|
end
|
|
|
|
poll = Poll.includes(:poll_options).find_by(post_id: post_id, name: poll_name)
|
|
|
|
unless poll
|
|
raise DiscoursePoll::Error.new I18n.t("poll.no_poll_with_this_name", name: poll_name)
|
|
end
|
|
raise DiscoursePoll::Error.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 DiscoursePoll::Error.new I18n.t("js.poll.results.groups.title", groups: poll.groups)
|
|
end
|
|
end
|
|
|
|
yield(poll)
|
|
|
|
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
|
|
end
|
|
end
|
|
end
|