FEATURE: Allow users to remove their vote (#14459)

They can use the remove vote button or select the same option again for
single choice polls.

This commit refactor the plugin to properly organize code and make it
easier to follow.
This commit is contained in:
Bianca Nenciu 2021-10-05 11:38:49 +03:00 committed by GitHub
parent 12856ab8c2
commit 6a143030f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 550 additions and 521 deletions

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
class DiscoursePoll::PollsController < ::ApplicationController
requires_plugin DiscoursePoll::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(current_user, post_id, poll_name, options)
render json: { poll: poll, vote: options }
rescue DiscoursePoll::Error => e
render_json_error e.message
end
end
def remove_vote
post_id = params.require(:post_id)
poll_name = params.require(:poll_name)
begin
poll = DiscoursePoll::Poll.remove_vote(current_user, post_id, poll_name)
render json: { poll: poll }
rescue DiscoursePoll::Error => 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(current_user, post_id, poll_name, status)
render json: { poll: poll }
rescue DiscoursePoll::Error => 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)
raise Discourse::InvalidParameters.new(:post_id) if !Post.where(id: post_id).exists?
poll = Poll.find_by(post_id: post_id, name: poll_name)
raise Discourse::InvalidParameters.new(:poll_name) if !poll&.can_see_voters?(current_user)
render json: { voters: DiscoursePoll::Poll.serialized_voters(poll, opts) }
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(current_user, post_id, poll_name, user_field_name)
}
rescue DiscoursePoll::Error => e
render_json_error e.message
end
end
end

View File

@ -109,6 +109,7 @@ function initializePolls(api) {
post: pollPost, post: pollPost,
poll, poll,
vote, vote,
hasSavedVote: vote.length > 0,
titleHTML: titleElement && titleElement.outerHTML, titleHTML: titleElement && titleElement.outerHTML,
groupableUserFields: ( groupableUserFields: (
api.container.lookup("site-settings:main") api.container.lookup("site-settings:main")

View File

@ -636,24 +636,44 @@ createWidget("discourse-poll-buttons", {
}) })
); );
} else { } else {
let showResultsButton;
let infoText;
if (poll.results === "on_vote" && !attrs.hasVoted && !isMe) { if (poll.results === "on_vote" && !attrs.hasVoted && !isMe) {
contents.push(infoTextHtml(I18n.t("poll.results.vote.title"))); infoText = infoTextHtml(I18n.t("poll.results.vote.title"));
} else if (poll.results === "on_close" && !closed) { } else if (poll.results === "on_close" && !closed) {
contents.push(infoTextHtml(I18n.t("poll.results.closed.title"))); infoText = infoTextHtml(I18n.t("poll.results.closed.title"));
} else if (poll.results === "staff_only" && !isStaff) { } else if (poll.results === "staff_only" && !isStaff) {
contents.push(infoTextHtml(I18n.t("poll.results.staff.title"))); infoText = infoTextHtml(I18n.t("poll.results.staff.title"));
} else { } else {
contents.push( showResultsButton = this.attach("button", {
this.attach("button", {
className: "btn-default toggle-results", className: "btn-default toggle-results",
label: "poll.show-results.label", label: "poll.show-results.label",
title: "poll.show-results.title", title: "poll.show-results.title",
icon: "far-eye", icon: "far-eye",
disabled: poll.voters === 0,
action: "toggleResults", action: "toggleResults",
});
}
if (showResultsButton) {
contents.push(showResultsButton);
}
if (attrs.hasSavedVote) {
contents.push(
this.attach("button", {
className: "btn-default remove-vote",
label: "poll.remove-vote.label",
title: "poll.remove-vote.title",
icon: "trash-alt",
action: "removeVote",
}) })
); );
} }
if (infoText) {
contents.push(infoText);
}
} }
if (attrs.groupableUserFields.length && poll.voters > 0) { if (attrs.groupableUserFields.length && poll.voters > 0) {
@ -894,6 +914,28 @@ export default createWidget("discourse-poll", {
this.state.showResults = !this.state.showResults; this.state.showResults = !this.state.showResults;
}, },
removeVote() {
const { attrs, state } = this;
state.loading = true;
return ajax("/polls/vote", {
type: "DELETE",
data: {
post_id: attrs.post.id,
poll_name: attrs.poll.name,
},
})
.then(({ poll }) => {
attrs.poll.setProperties(poll);
attrs.vote.length = 0;
attrs.hasSavedVote = false;
this.appEvents.trigger("poll:voted", poll, attrs.post, attrs.vote);
})
.catch((error) => popupAjaxError(error))
.finally(() => {
state.loading = false;
});
},
exportResults() { exportResults() {
const { attrs } = this; const { attrs } = this;
const queryID = this.siteSettings.poll_export_data_explorer_query_id; const queryID = this.siteSettings.poll_export_data_explorer_query_id;
@ -963,6 +1005,10 @@ export default createWidget("discourse-poll", {
} }
const { vote } = attrs; const { vote } = attrs;
if (!this.isMultiple() && vote.length === 1 && vote[0] === option.id) {
return this.removeVote();
}
if (!this.isMultiple()) { if (!this.isMultiple()) {
vote.length = 0; vote.length = 0;
} }
@ -994,6 +1040,7 @@ export default createWidget("discourse-poll", {
}, },
}) })
.then(({ poll }) => { .then(({ poll }) => {
attrs.hasSavedVote = true;
attrs.poll.setProperties(poll); attrs.poll.setProperties(poll);
this.appEvents.trigger("poll:voted", poll, attrs.post, attrs.vote); this.appEvents.trigger("poll:voted", poll, attrs.post, attrs.vote);

View File

@ -44,6 +44,10 @@ en:
title: "Display the poll results" title: "Display the poll results"
label: "Show results" label: "Show results"
remove-vote:
title: "Remove your vote"
label: "Remove vote"
hide-results: hide-results:
title: "Back to your votes" title: "Back to your votes"
label: "Show vote" label: "Show vote"

View File

@ -13,10 +13,10 @@ module Jobs
end end
DiscoursePoll::Poll.toggle_status( DiscoursePoll::Poll.toggle_status(
Discourse.system_user,
args[:post_id], args[:post_id],
args[:poll_name], args[:poll_name],
"closed", "closed",
Discourse.system_user,
false false
) )
end end

338
plugins/poll/lib/poll.rb Normal file
View File

@ -0,0 +1,338 @@
# frozen_string_literal: true
class DiscoursePoll::Poll
def self.vote(user, post_id, poll_name, options)
serialized_poll = DiscoursePoll::Poll.change_vote(user, post_id, poll_name) do |poll|
# 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 DiscoursePoll::Error.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
end
[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
raise DiscoursePoll::Error.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 DiscoursePoll::Error.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 DiscoursePoll::Error.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 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).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 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)
# 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?(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
if title_element
poll["title"] = title_element.inner_html.strip
end
poll
end
end
private
def self.change_vote(user, post_id, poll_name)
Poll.transaction do
post = Post.find_by(id: post_id)
# post must not be deleted
if post.nil? || post.trashed?
raise DiscoursePoll::Error.new I18n.t("poll.post_is_deleted")
end
# 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)
raise DiscoursePoll::Error.new I18n.t("poll.no_poll_with_this_name", name: poll_name) unless poll
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

View File

@ -7,35 +7,21 @@
# url: https://github.com/discourse/discourse/tree/main/plugins/poll # url: https://github.com/discourse/discourse/tree/main/plugins/poll
register_asset "stylesheets/common/poll.scss" 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/desktop/poll.scss", :desktop
register_asset "stylesheets/desktop/poll-ui-builder.scss", :desktop
register_asset "stylesheets/mobile/poll.scss", :mobile register_asset "stylesheets/mobile/poll.scss", :mobile
register_asset "stylesheets/common/poll-ui-builder.scss"
register_asset "stylesheets/desktop/poll-ui-builder.scss", :desktop
register_asset "stylesheets/common/poll-breakdown.scss"
register_svg_icon "far fa-check-square" register_svg_icon "far fa-check-square"
enabled_site_setting :poll_enabled enabled_site_setting :poll_enabled
hide_plugin if self.respond_to?(:hide_plugin) hide_plugin
PLUGIN_NAME ||= "discourse_poll"
DATA_PREFIX ||= "data-poll-"
after_initialize do 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 module ::DiscoursePoll
PLUGIN_NAME ||= "discourse_poll"
DATA_PREFIX ||= "data-poll-"
HAS_POLLS ||= "has_polls" HAS_POLLS ||= "has_polls"
DEFAULT_POLL_NAME ||= "poll" DEFAULT_POLL_NAME ||= "poll"
@ -43,434 +29,38 @@ after_initialize do
engine_name PLUGIN_NAME engine_name PLUGIN_NAME
isolate_namespace DiscoursePoll isolate_namespace DiscoursePoll
end end
class Error < StandardError; end
end end
class DiscoursePoll::Poll require_relative "app/controllers/polls_controller.rb"
class << self require_relative "app/models/poll_option.rb"
require_relative "app/models/poll_vote.rb"
def vote(post_id, poll_name, options, user) require_relative "app/models/poll.rb"
Poll.transaction do require_relative "app/serializers/poll_option_serializer.rb"
post = Post.find_by(id: post_id) require_relative "app/serializers/poll_serializer.rb"
require_relative "jobs/regular/close_poll.rb"
# post must not be deleted require_relative "lib/poll.rb"
if post.nil? || post.trashed? require_relative "lib/polls_updater.rb"
raise StandardError.new I18n.t("poll.post_is_deleted") require_relative "lib/polls_validator.rb"
end require_relative "lib/post_validator.rb"
# 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 current_user_voted
poll = Poll.includes(:post).find_by(id: params[:id])
raise Discourse::NotFound.new(:id) if poll.nil?
can_see_poll = Guardian.new(current_user).can_see_post?(poll.post)
raise Discourse::NotFound.new(:id) if !can_see_poll
presence = PollVote.where(poll: poll, user: current_user).exists?
render json: { voted: presence }
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 DiscoursePoll::Engine.routes.draw do
put "/vote" => "polls#vote" put "/vote" => "polls#vote"
delete "/vote" => "polls#remove_vote"
put "/toggle_status" => "polls#toggle_status" put "/toggle_status" => "polls#toggle_status"
get "/voters" => 'polls#voters' get "/voters" => 'polls#voters'
get "/grouped_poll_results" => 'polls#grouped_poll_results' get "/grouped_poll_results" => 'polls#grouped_poll_results'
get "/groupable_user_fields" => 'polls#groupable_user_fields'
get "/:id/votes/current_user_voted" => "polls#current_user_voted"
end end
Discourse::Application.routes.append do Discourse::Application.routes.append do
mount ::DiscoursePoll::Engine, at: "/polls" mount ::DiscoursePoll::Engine, at: "/polls"
end end
allow_new_queued_post_payload_attribute("is_poll")
register_post_custom_field_type(DiscoursePoll::HAS_POLLS, :boolean)
topic_view_post_custom_fields_allowlister { [DiscoursePoll::HAS_POLLS] }
reloadable_patch do reloadable_patch do
Post.class_eval do Post.class_eval do
attr_accessor :extracted_polls attr_accessor :extracted_polls
@ -519,8 +109,6 @@ after_initialize do
true true
end end
allow_new_queued_post_payload_attribute("is_poll")
NewPostManager.add_handler(1) do |manager| NewPostManager.add_handler(1) do |manager|
post = Post.new(raw: manager.args[:raw]) post = Post.new(raw: manager.args[:raw])
@ -581,10 +169,6 @@ after_initialize do
PollVote.where(user_id: source_user.id).update_all(user_id: target_user.id) PollVote.where(user_id: source_user.id).update_all(user_id: target_user.id)
end 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 add_to_class(:topic_view, :polls) do
@polls ||= begin @polls ||= begin
polls = {} polls = {}

View File

@ -13,7 +13,6 @@ describe ::DiscoursePoll::PollsController do
let(:public_poll_on_close) { Fabricate(:post, topic: topic, user: user, raw: "[poll public=true results=on_close]\n- A\n- B\n[/poll]") } let(:public_poll_on_close) { Fabricate(:post, topic: topic, user: user, raw: "[poll public=true results=on_close]\n- A\n- B\n[/poll]") }
describe "#vote" do describe "#vote" do
it "works" do it "works" do
channel = "/polls/#{poll.topic_id}" channel = "/polls/#{poll.topic_id}"
@ -118,6 +117,24 @@ describe ::DiscoursePoll::PollsController do
expect(json["poll"]["options"][1]["votes"]).to eq(1) expect(json["poll"]["options"][1]["votes"]).to eq(1)
end end
it "supports removing votes" do
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"]
}, format: :json
expect(response.status).to eq(200)
delete :remove_vote, params: {
post_id: poll.id, poll_name: "poll"
}, format: :json
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["poll"]["voters"]).to eq(0)
expect(json["poll"]["options"][0]["votes"]).to eq(0)
expect(json["poll"]["options"][1]["votes"]).to eq(0)
end
it "works on closed topics" do it "works on closed topics" do
topic.update_attribute(:closed, true) topic.update_attribute(:closed, true)
@ -218,7 +235,6 @@ describe ::DiscoursePoll::PollsController do
end end
describe "#toggle_status" do describe "#toggle_status" do
it "works for OP" do it "works for OP" do
channel = "/polls/#{poll.topic_id}" channel = "/polls/#{poll.topic_id}"
@ -264,11 +280,9 @@ describe ::DiscoursePoll::PollsController do
json = response.parsed_body json = response.parsed_body
expect(json["errors"][0]).to eq(I18n.t("poll.post_is_deleted")) expect(json["errors"][0]).to eq(I18n.t("poll.post_is_deleted"))
end end
end end
describe "#voters" do describe "#voters" do
let(:first) { "5c24fc1df56d764b550ceae1b9319125" } let(:first) { "5c24fc1df56d764b550ceae1b9319125" }
let(:second) { "e89dec30bbd9bf50fabf6a05b4324edf" } let(:second) { "e89dec30bbd9bf50fabf6a05b4324edf" }
@ -333,7 +347,7 @@ describe ::DiscoursePoll::PollsController do
poll_name: "poll", post_id: public_poll_on_vote.id poll_name: "poll", post_id: public_poll_on_vote.id
}, format: :json }, format: :json
expect(response.status).to eq(422) expect(response.status).to eq(400)
put :vote, params: { put :vote, params: {
post_id: public_poll_on_vote.id, poll_name: "poll", options: [second] post_id: public_poll_on_vote.id, poll_name: "poll", options: [second]
@ -364,7 +378,7 @@ describe ::DiscoursePoll::PollsController do
poll_name: "poll", post_id: public_poll_on_close.id poll_name: "poll", post_id: public_poll_on_close.id
}, format: :json }, format: :json
expect(response.status).to eq(422) expect(response.status).to eq(400)
put :toggle_status, params: { put :toggle_status, params: {
post_id: public_poll_on_close.id, poll_name: "poll", status: "closed" post_id: public_poll_on_close.id, poll_name: "poll", status: "closed"
@ -382,51 +396,5 @@ describe ::DiscoursePoll::PollsController do
expect(json["voters"][first].size).to eq(1) expect(json["voters"][first].size).to eq(1)
end end
end
describe '#current_user_voted' do
let(:logged_user) { Fabricate(:user) }
let(:post_with_poll) { Fabricate(:post, raw: "[poll]\n- A\n- B\n[/poll]") }
before { log_in_user(logged_user) }
it 'returns true if the logged user already voted' do
poll = post_with_poll.polls.last
PollVote.create!(poll: poll, user: logged_user)
get :current_user_voted, params: { id: poll.id }, format: :json
parsed_body = JSON.parse(response.body)
expect(response.status).to eq(200)
expect(parsed_body['voted']).to eq(true)
end
it 'returns a 404 if there is no poll' do
unknown_poll_id = 999999
get :current_user_voted, params: { id: unknown_poll_id }, format: :json
expect(response.status).to eq(404)
end
it "returns a 404 if the user doesn't have access to the poll" do
pm_with_poll = Fabricate(:private_message_post, raw: "[poll]\n- A\n- B\n[/poll]")
poll = pm_with_poll.polls.last
get :current_user_voted, params: { id: poll.id }, format: :json
expect(response.status).to eq(404)
end
it "returns false if the user didn't vote yet" do
poll = post_with_poll.polls.last
get :current_user_voted, params: { id: poll.id }, format: :json
parsed_body = JSON.parse(response.body)
expect(response.status).to eq(200)
expect(parsed_body['voted']).to eq(false)
end
end end
end end

View File

@ -185,7 +185,7 @@ describe PostsController do
end end
it "resets the votes" do it "resets the votes" do
DiscoursePoll::Poll.vote(post_id, "poll", ["5c24fc1df56d764b550ceae1b9319125"], user) DiscoursePoll::Poll.vote(user, post_id, "poll", ["5c24fc1df56d764b550ceae1b9319125"])
put :update, params: { put :update, params: {
id: post_id, post: { raw: "[poll]\n- A\n- B\n- C\n[/poll]" } id: post_id, post: { raw: "[poll]\n- A\n- B\n- C\n[/poll]" }
@ -244,7 +244,7 @@ describe PostsController do
describe "with at least one vote" do describe "with at least one vote" do
before do before do
DiscoursePoll::Poll.vote(post_id, "poll", ["5c24fc1df56d764b550ceae1b9319125"], user) DiscoursePoll::Poll.vote(user, post_id, "poll", ["5c24fc1df56d764b550ceae1b9319125"])
end end
it "cannot change the options" do it "cannot change the options" do

View File

@ -11,10 +11,10 @@ describe "DiscoursePoll endpoints" do
it "should return the right response" do it "should return the right response" do
DiscoursePoll::Poll.vote( DiscoursePoll::Poll.vote(
user,
post.id, post.id,
DiscoursePoll::DEFAULT_POLL_NAME, DiscoursePoll::DEFAULT_POLL_NAME,
[option_a], [option_a]
user
) )
get "/polls/voters.json", params: { get "/polls/voters.json", params: {
@ -34,10 +34,10 @@ describe "DiscoursePoll endpoints" do
it 'should return the right response for a single option' do it 'should return the right response for a single option' do
DiscoursePoll::Poll.vote( DiscoursePoll::Poll.vote(
user,
post.id, post.id,
DiscoursePoll::DEFAULT_POLL_NAME, DiscoursePoll::DEFAULT_POLL_NAME,
[option_a, option_b], [option_a, option_b]
user
) )
get "/polls/voters.json", params: { get "/polls/voters.json", params: {
@ -72,7 +72,7 @@ describe "DiscoursePoll endpoints" do
post_id: -1, post_id: -1,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME poll_name: DiscoursePoll::DEFAULT_POLL_NAME
} }
expect(response.status).to eq(422) expect(response.status).to eq(400)
expect(response.body).to include('post_id') expect(response.body).to include('post_id')
end end
end end
@ -87,7 +87,7 @@ describe "DiscoursePoll endpoints" do
describe 'when poll_name is not valid' do describe 'when poll_name is not valid' do
it 'should raise the right error' do it 'should raise the right error' do
get "/polls/voters.json", params: { post_id: post.id, poll_name: 'wrongpoll' } get "/polls/voters.json", params: { post_id: post.id, poll_name: 'wrongpoll' }
expect(response.status).to eq(422) expect(response.status).to eq(400)
expect(response.body).to include('poll_name') expect(response.body).to include('poll_name')
end end
end end
@ -99,10 +99,10 @@ describe "DiscoursePoll endpoints" do
post post
DiscoursePoll::Poll.vote( DiscoursePoll::Poll.vote(
user,
post.id, post.id,
DiscoursePoll::DEFAULT_POLL_NAME, DiscoursePoll::DEFAULT_POLL_NAME,
["4d8a15e3cc35750f016ce15a43937620"], ["4d8a15e3cc35750f016ce15a43937620"]
user
) )
get "/polls/voters.json", params: { get "/polls/voters.json", params: {
@ -137,20 +137,20 @@ describe "DiscoursePoll endpoints" do
} }
[user1, user2, user3].each_with_index do |user, index| [user1, user2, user3].each_with_index do |user, index|
DiscoursePoll::Poll.vote( DiscoursePoll::Poll.vote(
user,
post.id, post.id,
DiscoursePoll::DEFAULT_POLL_NAME, DiscoursePoll::DEFAULT_POLL_NAME,
[user_votes["user_#{index}".to_sym]], [user_votes["user_#{index}".to_sym]]
user
) )
UserCustomField.create(user_id: user.id, name: "something", value: "value#{index}") UserCustomField.create(user_id: user.id, name: "something", value: "value#{index}")
end end
# Add another user to one of the fields to prove it groups users properly # Add another user to one of the fields to prove it groups users properly
DiscoursePoll::Poll.vote( DiscoursePoll::Poll.vote(
user4,
post.id, post.id,
DiscoursePoll::DEFAULT_POLL_NAME, DiscoursePoll::DEFAULT_POLL_NAME,
[option_a, option_b], [option_a, option_b]
user4
) )
UserCustomField.create(user_id: user4.id, name: "something", value: "value1") UserCustomField.create(user_id: user4.id, name: "something", value: "value1")
end end
@ -182,7 +182,7 @@ describe "DiscoursePoll endpoints" do
user_field_name: "something" user_field_name: "something"
} }
expect(response.status).to eq(422) expect(response.status).to eq(400)
expect(response.body).to include('user_field_name') expect(response.body).to include('user_field_name')
end end
end end

View File

@ -78,8 +78,8 @@ describe DiscoursePoll::PollsUpdater do
let(:post) { Fabricate(:post, raw: raw) } let(:post) { Fabricate(:post, raw: raw) }
it "works if poll is closed and unmodified" do it "works if poll is closed and unmodified" do
DiscoursePoll::Poll.vote(post.id, "poll", ["e55de753c08b93d04d677ce05e942d3c"], post.user) DiscoursePoll::Poll.vote(post.user, post.id, "poll", ["e55de753c08b93d04d677ce05e942d3c"])
DiscoursePoll::Poll.toggle_status(post.id, "poll", "closed", post.user) DiscoursePoll::Poll.toggle_status(post.user, post.id, "poll", "closed")
freeze_time (SiteSetting.poll_edit_window_mins + 1).minutes.from_now freeze_time (SiteSetting.poll_edit_window_mins + 1).minutes.from_now
update(post, DiscoursePoll::PollsValidator.new(post).validate_polls) update(post, DiscoursePoll::PollsValidator.new(post).validate_polls)
@ -159,7 +159,7 @@ describe DiscoursePoll::PollsUpdater do
before do before do
expect { expect {
DiscoursePoll::Poll.vote(post.id, "poll", [polls["poll"]["options"][0]["id"]], user) DiscoursePoll::Poll.vote(user, post.id, "poll", [polls["poll"]["options"][0]["id"]])
}.to change { PollVote.count }.by(1) }.to change { PollVote.count }.by(1)
end end

View File

@ -1,5 +1,6 @@
import { import {
acceptance, acceptance,
count,
publishToMessageBus, publishToMessageBus,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit"; import { test } from "qunit";
@ -558,6 +559,8 @@ acceptance("Poll results", function (needs) {
}); });
} }
}); });
server.delete("/polls/vote", () => helper.response({ success: "OK" }));
}); });
test("can load more voters", async function (assert) { test("can load more voters", async function (assert) {
@ -643,4 +646,17 @@ acceptance("Poll results", function (needs) {
0 0
); );
}); });
test("can unvote", async function (assert) {
await visit("/t/-/load-more-poll-voters");
await click(".toggle-results");
assert.equal(count(".poll-container .d-icon-circle"), 1);
assert.equal(count(".poll-container .d-icon-far-circle"), 1);
await click(".remove-vote");
assert.equal(count(".poll-container .d-icon-circle"), 0);
assert.equal(count(".poll-container .d-icon-far-circle"), 2);
});
}); });

View File

@ -75,7 +75,6 @@ discourseModule(
], ],
voters: 1, voters: 1,
chart_type: "bar", chart_type: "bar",
groups: "foo",
}, },
vote: ["1f972d1df351de3ce35a787c89faad29"], vote: ["1f972d1df351de3ce35a787c89faad29"],
}, },