# 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 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 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