# frozen_string_literal: true # name: discourse-topic-voting # about: Adds the ability to vote on features in a specified category. # version: 0.5 # author: Joe Buhlig joebuhlig.com, Sam Saffron # url: https://github.com/discourse/discourse-topic-voting register_asset "stylesheets/common/topic-voting.scss" register_asset "stylesheets/desktop/topic-voting.scss", :desktop register_asset "stylesheets/mobile/topic-voting.scss", :mobile enabled_site_setting :voting_enabled Discourse.top_menu_items.push(:votes) Discourse.anonymous_top_menu_items.push(:votes) Discourse.filters.push(:votes) Discourse.anonymous_filters.push(:votes) after_initialize do module ::DiscourseTopicVoting class Engine < ::Rails::Engine isolate_namespace DiscourseTopicVoting end end load File.expand_path('../app/jobs/onceoff/voting_ensure_consistency.rb', __FILE__) load File.expand_path('../app/models/discourse_topic_voting/category_setting.rb', __FILE__) load File.expand_path('../app/models/discourse_topic_voting/topic_vote_count.rb', __FILE__) load File.expand_path('../app/models/discourse_topic_voting/vote.rb', __FILE__) load File.expand_path('../lib/discourse_topic_voting/categories_controller_extension.rb', __FILE__) load File.expand_path('../lib/discourse_topic_voting/category_extension.rb', __FILE__) load File.expand_path('../lib/discourse_topic_voting/topic_extension.rb', __FILE__) load File.expand_path('../lib/discourse_topic_voting/user_extension.rb', __FILE__) reloadable_patch do CategoriesController.class_eval { prepend DiscourseTopicVoting::CategoriesControllerExtension } Category.class_eval { prepend DiscourseTopicVoting::CategoryExtension } Topic.class_eval { prepend DiscourseTopicVoting::TopicExtension } User.class_eval { prepend DiscourseTopicVoting::UserExtension } add_to_serializer(:post, :can_topic_vote, false) { object.topic&.can_topic_vote? } add_to_serializer(:post, :include_can_topic_vote?) { SiteSetting.voting_enabled && object.post_number == 1 } add_to_serializer(:topic_view, :can_topic_vote) { object.topic.can_topic_vote? } add_to_serializer(:topic_view, :topic_vote_count) { object.topic.topic_topic_vote_count } add_to_serializer(:topic_view, :user_topic_voted) { scope.user ? object.topic.user_topic_voted?(scope.user) : false } if TopicQuery.respond_to?(:results_filter_callbacks) TopicQuery.results_filter_callbacks << ->(_type, result, user, options) { result = result.includes(:topic_vote_count) if user result = result.select("topics.*, COALESCE((SELECT 1 FROM discourse_voting_votes WHERE user_id = #{user.id} AND topic_id = topics.id), 0) AS current_user_voted") if options[:state] == "my_votes" result = result.joins("INNER JOIN discourse_voting_votes ON discourse_voting_votes.topic_id = topics.id AND discourse_voting_votes.user_id = #{user.id}") end end if options[:order] == "votes" sort_dir = (options[:ascending] == "true") ? "ASC" : "DESC" result = result .joins("LEFT JOIN discourse_voting_topic_vote_count ON discourse_voting_topic_vote_count.topic_id = topics.id") .reorder("COALESCE(discourse_voting_topic_vote_count.votes_count,'0')::integer #{sort_dir}, topics.bumped_at DESC") end result } end add_to_serializer(:category, :custom_fields) do object.custom_fields.merge(enable_topic_voting: DiscourseTopicVoting::CategorySetting.find_by(category_id: object.id).present?) end add_to_serializer(:topic_list_item, :topic_vote_count, false) { object.topic_topic_vote_count } add_to_serializer(:topic_list_item, :can_topic_vote, false) { object.can_topic_vote? } add_to_serializer(:topic_list_item, :user_topic_voted, false) { object.user_topic_voted?(scope.user) if scope.user } add_to_serializer(:topic_list_item, :include_topic_vote_count?) { object.can_topic_vote? } add_to_serializer(:topic_list_item, :include_can_topic_vote?) { SiteSetting.voting_enabled && object.regular? } add_to_serializer(:topic_list_item, :include_user_topic_voted?) { object.can_topic_vote? } add_to_serializer(:basic_category, :can_vote, false) { true } add_to_serializer(:basic_category, :include_can_vote?) { Category.can_vote?(object.id) } register_search_advanced_filter(/^min_vote_count:(\d+)$/) do |posts, match| posts.where("(SELECT votes_count FROM discourse_voting_topic_vote_count WHERE discourse_voting_topic_vote_count.topic_id = posts.topic_id) >= ?", match.to_i) end register_search_advanced_order(:votes) do |posts| posts.reorder("COALESCE((SELECT dvtvc.votes_count FROM discourse_voting_topic_vote_count dvtvc WHERE dvtvc.topic_id = topics.id), 0) DESC") end class ::Category def self.reset_voting_cache @allowed_voting_cache["allowed"] = begin DiscourseTopicVoting::CategorySetting.pluck(:category_id) end end @allowed_voting_cache = DistributedCache.new("allowed_voting") def self.can_vote?(category_id) return false unless SiteSetting.voting_enabled unless set = @allowed_voting_cache["allowed"] set = reset_voting_cache end set.include?(category_id) end after_save :reset_voting_cache protected def reset_voting_cache ::Category.reset_voting_cache end end require_dependency 'user' class ::User def topic_vote_count topics_with_topic_vote.length end def alert_low_topic_votes? (topic_vote_limit - topic_vote_count) <= SiteSetting.voting_alert_votes_left end def topics_with_topic_vote self.votes.where(archive: false) end def topics_with_archived_topic_vote self.votes.where(archive: true) end def reached_topic_voting_limit? topic_vote_count >= topic_vote_limit end def topic_vote_limit SiteSetting.public_send("voting_tl#{self.trust_level}_vote_limit") end end add_to_serializer(:current_user, :topic_votes_exceeded) { object.reached_topic_voting_limit? } add_to_serializer(:current_user, :topic_votes_count) { object.topic_vote_count } add_to_serializer(:current_user, :topic_votes_left) { [object.topic_vote_limit - object.topic_vote_count, 0].max } require_dependency 'list_controller' class ::ListController skip_before_action :ensure_logged_in, only: %i[voted_by] def voted_by if SiteSetting.voting_show_votes_on_profile list_opts = build_topic_list_options target_user = fetch_user_from_params(include_inactive: current_user.try(:staff?)) list = generate_list_for("voted_by", target_user, list_opts) list.more_topics_url = url_for(construct_url_with(:next, list_opts)) list.prev_topics_url = url_for(construct_url_with(:prev, list_opts)) respond_with_list(list) else respond_to do |format| format.html { render nothing: true, status: 404 } format.json { render json: failed_json, status: 404 } end end end end require_dependency 'topic_query' class ::TopicQuery def list_voted_by(user) create_list(:user_topics) do |topics| topics .joins("INNER JOIN discourse_voting_votes ON discourse_voting_votes.topic_id = topics.id") .where("discourse_voting_votes.user_id = ?", user.id) end end def list_votes create_list(:votes, unordered: true) do |topics| topics.joins("LEFT JOIN discourse_voting_topic_vote_count dvtvc ON dvtvc.topic_id = topics.id") .order("COALESCE(dvtvc.votes_count,'0')::integer DESC, topics.bumped_at DESC") end end end require_dependency "jobs/base" module ::Jobs class VoteRelease < ::Jobs::Base def execute(args) if topic = Topic.with_deleted.find_by(id: args[:topic_id]) votes = DiscourseTopicVoting::Vote.where(topic_id: args[:topic_id]) votes.update_all(archive: true) topic.update_vote_count return if args[:trashed] votes.find_each do |vote| Notification.create!(user_id: vote.user_id, notification_type: Notification.types[:votes_released], topic_id: vote.topic_id, data: { message: "votes_released", title: "votes_released" }.to_json) rescue # If one notifcation crash, inform others end end end end class VoteReclaim < ::Jobs::Base def execute(args) if topic = Topic.with_deleted.find_by(id: args[:topic_id]) ActiveRecord::Base.transaction do DiscourseTopicVoting::Vote.where(topic_id: args[:topic_id]).update_all(archive: false) topic.update_vote_count end end end end end end DiscourseEvent.on(:topic_status_updated) do |topic, status, enabled| if (status == 'closed' || status == 'autoclosed' || status == 'archived') && enabled == true Jobs.enqueue(:vote_release, topic_id: topic.id) end if (status == 'closed' || status == 'autoclosed' || status == 'archived') && enabled == false Jobs.enqueue(:vote_reclaim, topic_id: topic.id) end end DiscourseEvent.on(:topic_trashed) do |topic| Jobs.enqueue(:vote_release, topic_id: topic.id, trashed: true) if !topic.closed && !topic.archived end DiscourseEvent.on(:topic_recovered) do |topic| Jobs.enqueue(:vote_reclaim, topic_id: topic.id) if !topic.closed && !topic.archived end DiscourseEvent.on(:post_edited) do |post, topic_changed| if topic_changed && SiteSetting.voting_enabled && DiscourseTopicVoting::Vote.exists?(topic_id: post.topic_id) new_category_id = post.reload.topic.category_id if Category.can_vote?(new_category_id) Jobs.enqueue(:vote_reclaim, topic_id: post.topic_id) else Jobs.enqueue(:vote_release, topic_id: post.topic_id) end end end DiscourseEvent.on(:topic_merged) do |orig, dest| moved_votes = 0 duplicated_votes = 0 if orig.who_voted.present? && orig.closed orig.who_voted.each do |user| next if user.blank? user_votes = user.topics_with_topic_vote.pluck(:topic_id) user_archived_votes = user.topics_with_archived_topic_vote.pluck(:topic_id) if user_votes.include?(orig.id) || user_archived_votes.include?(orig.id) if user_votes.include?(dest.id) || user_archived_votes.include?(dest.id) duplicated_votes += 1 user.votes.destroy_by(topic_id: orig.id) else user.votes.find_by(topic_id: orig.id, user_id: user.id).update!(topic_id: dest.id, archive: dest.closed) moved_votes += 1 end else next end end end if moved_votes > 0 orig.update_vote_count dest.update_vote_count if moderator_post = orig.ordered_posts.where(action_code: 'split_topic').last moderator_post.raw << "\n\n#{I18n.t('topic_voting.votes_moved', count: moved_votes)}" moderator_post.raw << " #{I18n.t('topic_voting.duplicated_votes', count: duplicated_votes)}" if duplicated_votes > 0 moderator_post.save! end end end require File.expand_path(File.dirname(__FILE__) + '/app/controllers/discourse_topic_voting/votes_controller') DiscourseTopicVoting::Engine.routes.draw do post 'vote' => 'votes#vote' post 'unvote' => 'votes#unvote' get 'who' => 'votes#who' end Discourse::Application.routes.append do mount ::DiscourseTopicVoting::Engine, at: "/voting" # USERNAME_ROUTE_FORMAT is deprecated but we may need to support it for older installs username_route_format = defined?(RouteFormat) ? RouteFormat.username : USERNAME_ROUTE_FORMAT get "topics/voted-by/:username" => "list#voted_by", as: "voted_by", constraints: { username: username_route_format } end end