mirror of
https://github.com/discourse/discourse.git
synced 2026-08-05 15:18:34 +08:00
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
279 lines
8.5 KiB
Ruby
Vendored
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
|