mirror of
https://github.com/discourse/discourse.git
synced 2025-01-31 12:56:47 +08:00
12cba2ce24
Whenever a post already failed "lightweight" validations, we skip all the expensive validations (that cooks the post or run SQL queries) so that we reply as soon as possible. Also skip validating polls when there's no "[/poll]" in the raw. Internal ref - t/115890
421 lines
13 KiB
Ruby
421 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class DiscoursePoll::Poll
|
|
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
|
|
options.select! { |o| available_options.include?(o) }
|
|
|
|
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|
|
|
obj << option.id if options.include?(option.digest)
|
|
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
|
|
|
|
# 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
|
|
end
|
|
|
|
# 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
|
|
|
|
DB.query(<<~SQL, poll_id: poll_id, user_id: user.id, offset: offset)
|
|
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
|
|
|
|
[serialized_poll, options]
|
|
end
|
|
|
|
def self.remove_vote(user, post_id, poll_name)
|
|
DiscoursePoll::Poll.change_vote(user, post_id, poll_name) do |poll|
|
|
PollVote.where(poll: poll, user: user).delete_all
|
|
end
|
|
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 = {})
|
|
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 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
|
|
|
|
unless SiteSetting.poll_groupable_user_fields.split("|").include?(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 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
|