0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 15:18:34 +08:00
discourse/plugins/poll/plugin.rb
Régis Hanol 4907973cf7
FIX: Don't duplicate the poll title in emails (#42158)
Previously, the `reduce_cooked` handler appended the `.poll-title`
element and then the inner HTML of `.poll-container`. Since the markdown
rule nests the title inside that container, every poll's title showed up
twice in HTML emails — and, since they share the same code path, in
digests, embedded comments and RSS feeds too.

This change drops the redundant append, leaving the container as the
single source of the poll's content, and tightens the email specs to
assert on the whole reduced fragment rather than with `include`, which a
duplicate satisfies just as well as a single copy.

https://meta.discourse.org/t/408875
2026-07-30 13:02:09 +02:00

279 lines
8.5 KiB
Ruby
Vendored

# 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/main/plugins/poll
register_asset "stylesheets/common/poll.scss"
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-square-check"
enabled_site_setting :poll_enabled
module ::DiscoursePoll
PLUGIN_NAME = "poll"
end
require_relative "lib/poll/engine"
after_initialize do
require_relative "app/controllers/discourse_poll/polls_controller"
require_relative "app/models/poll_option"
require_relative "app/models/poll_vote"
require_relative "app/models/poll"
require_relative "app/serializers/poll_option_serializer"
require_relative "app/serializers/poll_serializer"
require_relative "jobs/regular/close_poll"
require_relative "lib/poll"
require_relative "lib/ranked_choice"
require_relative "lib/polls_updater"
require_relative "lib/polls_validator"
require_relative "lib/post_validator"
require_relative "lib/post_extension"
require_relative "lib/user_extension"
Discourse::Application.routes.append { mount DiscoursePoll::Engine, at: "/polls" }
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
Post.prepend(DiscoursePoll::PostExtension)
User.prepend(DiscoursePoll::UserExtension)
end
validate(:post, :validate_polls) do |force = nil|
return unless raw_changed? || force
validator = DiscoursePoll::PollsValidator.new(self)
return unless (polls = validator.validate_polls)
return if polls.blank? && id.blank?
if polls.present?
validator = DiscoursePoll::PostValidator.new(self)
return unless validator.validate_post
end
# are we updating a post?
if id.present?
return if polls.blank? && ::Poll.where(post: self).empty?
DiscoursePoll::PollsUpdater.update(self, polls)
else
self.extracted_polls = polls
end
true
end
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 { |message| result.add_error(message) }
result
else
manager.args["is_poll"] = true
nil
end
end
on(:approved_post) do |queued_post, created_post|
created_post.validate_polls(true) if queued_post.payload["is_poll"]
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|
html = +""
if container = poll.at_css(".poll-container")
container.css("li").each { |li| li.remove_attribute("data-poll-option-id") }
html << container.inner_html
end
html << "<p><a href='#{post_url}'>#{I18n.t("poll.email.link_to_poll")}</a></p>"
poll.replace(html)
end
end
end
on(:reduce_excerpt) do |doc, options|
post = options[:post]
if post&.url.present?
replacement = "<a href='#{UrlHelper.normalized_encode(post.url)}'>#{I18n.t("poll.poll")}</a>"
else
replacement = I18n.t("poll.poll")
end
doc.css("div.poll").each { |poll| poll.replace(replacement) }
end
on(:post_created) do |post, _opts, user|
guardian = Guardian.new(user)
DiscoursePoll::Poll.schedule_jobs(post)
next if post.is_first_post?
next if post.custom_fields[DiscoursePoll::HAS_POLLS].blank?
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
on(:post_moved) do |new_post, _original_topic_id, old_post|
next if old_post.blank? || new_post.id == old_post.id
ActiveRecord::Base.transaction do
# Remove empty polls auto-created by PostCreator on the new post
# so the originals (with votes) can be moved without a unique constraint violation.
new_polls = Poll.where(post_id: new_post.id)
if new_polls.exists?
PollVote.where(poll_id: new_polls.select(:id)).delete_all
PollOption.where(poll_id: new_polls.select(:id)).delete_all
new_polls.delete_all
end
Poll.where(post_id: old_post.id).update_all(post_id: new_post.id)
end
DiscoursePoll::PollsUpdater.update_post_custom_fields(new_post)
DiscoursePoll::PollsUpdater.update_post_custom_fields(old_post)
end
on(:merging_users) do |source_user, target_user|
DB.exec(<<-SQL, source_user_id: source_user.id, target_user_id: target_user.id)
DELETE FROM poll_votes
WHERE user_id = :source_user_id
AND EXISTS (
SELECT 1
FROM poll_votes
WHERE user_id = :target_user_id
AND poll_votes.poll_id = poll_votes.poll_id
);
UPDATE poll_votes
SET user_id = :target_user_id
WHERE user_id = :source_user_id;
SQL
end
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?
all_polls = Poll.includes(:poll_options, :closed_by).where(post_id: post_with_polls)
Poll.preload!(all_polls, user_id: @user&.id)
DiscoursePoll::Poll.preload_serialized_voters!(all_polls)
all_polls.each do |p|
polls[p.post_id] ||= []
polls[p.post_id] << p
end
end
polls
end
end
add_to_serializer(:current_user, :can_create_poll) do
scope.user&.staff? || scope.user&.in_any_groups?(SiteSetting.poll_create_allowed_groups_map)
end
add_to_class(PostSerializer, :preloaded_polls) do
@preloaded_polls ||=
if @topic_view.present?
@topic_view.polls[object.id]
else
Poll.includes(:poll_options, :closed_by).where(post: object)
end
end
add_to_serializer(:post, :polls, include_condition: -> { preloaded_polls.present? }) do
preloaded_polls.map { |p| PollSerializer.new(p, root: false, scope: scope) }
end
add_to_serializer(
:post,
:polls_votes,
include_condition: -> do
scope.user&.id.present? && preloaded_polls.present? &&
preloaded_polls.any? { |p| p.has_voted?(scope.user) }
end,
) do
preloaded_polls
.map do |poll|
if poll.ranked_choice?
user_poll_votes =
poll
.poll_votes
.where(user_id: scope.user.id)
.joins(:poll_option)
.pluck("poll_options.digest", "poll_votes.rank")
.map { |digest, rank| { digest: digest, rank: rank } }
else
user_poll_votes =
poll
.poll_votes
.where(user_id: scope.user.id)
.joins(:poll_option)
.pluck("poll_options.digest")
end
[poll.name, user_poll_votes]
end
.to_h
end
register_search_advanced_filter(/in:polls/) do |posts, match|
if SiteSetting.poll_enabled
posts.joins(:polls)
else
posts
end
end
# Only single-choice (regular) polls are supported for anonymous voting.
# Multi-choice and ranked-choice polls require accumulating selections
# client-side, which is out of scope for the current anonymous flow.
register_anonymous_action("vote_poll") do |user, params|
options = params["options"]
next if !options.is_a?(Array) || options.size != 1
DiscoursePoll::Poll.vote(user, params["post_id"], params["poll_name"], options)
rescue DiscoursePoll::Error
# Expected business-logic failures (poll closed, group-restricted, etc.)
# — silently drop. Unexpected exceptions still bubble to AnonymousAction.consume.
end
# Protect users who have cast poll votes from the inactive user cleanup job.
register_modifier(:clean_up_inactive_users_query) do |relation|
relation.where("NOT EXISTS (SELECT 1 FROM poll_votes WHERE poll_votes.user_id = users.id)")
end
end