871 lines
27 KiB
Ruby
871 lines
27 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# name: discourse-solved
|
|
# about: Allows users to accept solutions on topics in designated categories.
|
|
# meta_topic_id: 30155
|
|
# version: 0.1
|
|
# authors: Sam Saffron
|
|
# url: https://github.com/discourse/discourse-solved
|
|
|
|
enabled_site_setting :solved_enabled
|
|
|
|
if respond_to?(:register_svg_icon)
|
|
register_svg_icon "far fa-check-square"
|
|
register_svg_icon "check-square"
|
|
register_svg_icon "far fa-square"
|
|
end
|
|
|
|
PLUGIN_NAME = "discourse_solved"
|
|
|
|
register_asset "stylesheets/solutions.scss"
|
|
register_asset "stylesheets/mobile/solutions.scss", :mobile
|
|
|
|
after_initialize do
|
|
SeedFu.fixture_paths << Rails.root.join("plugins", "discourse-solved", "db", "fixtures").to_s
|
|
|
|
%w[
|
|
../app/lib/first_accepted_post_solution_validator.rb
|
|
../app/lib/accepted_answer_cache.rb
|
|
../app/lib/guardian_extensions.rb
|
|
../app/serializers/concerns/topic_answer_mixin.rb
|
|
].each { |path| load File.expand_path(path, __FILE__) }
|
|
|
|
skip_db = defined?(GlobalSetting.skip_db?) && GlobalSetting.skip_db?
|
|
|
|
reloadable_patch { |plugin| Guardian.prepend(DiscourseSolved::GuardianExtensions) }
|
|
|
|
# we got to do a one time upgrade
|
|
if !skip_db && defined?(UserAction::SOLVED)
|
|
unless Discourse.redis.get("solved_already_upgraded")
|
|
unless UserAction.where(action_type: UserAction::SOLVED).exists?
|
|
Rails.logger.info("Upgrading storage for solved")
|
|
sql = <<SQL
|
|
INSERT INTO user_actions(action_type,
|
|
user_id,
|
|
target_topic_id,
|
|
target_post_id,
|
|
acting_user_id,
|
|
created_at,
|
|
updated_at)
|
|
SELECT :solved,
|
|
p.user_id,
|
|
p.topic_id,
|
|
p.id,
|
|
t.user_id,
|
|
pc.created_at,
|
|
pc.updated_at
|
|
FROM
|
|
post_custom_fields pc
|
|
JOIN
|
|
posts p ON p.id = pc.post_id
|
|
JOIN
|
|
topics t ON t.id = p.topic_id
|
|
WHERE
|
|
pc.name = 'is_accepted_answer' AND
|
|
pc.value = 'true' AND
|
|
p.user_id IS NOT NULL
|
|
SQL
|
|
|
|
DB.exec(sql, solved: UserAction::SOLVED)
|
|
end
|
|
Discourse.redis.set("solved_already_upgraded", "true")
|
|
end
|
|
end
|
|
|
|
module ::DiscourseSolved
|
|
class Engine < ::Rails::Engine
|
|
engine_name PLUGIN_NAME
|
|
isolate_namespace DiscourseSolved
|
|
end
|
|
|
|
AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD = "solved_auto_close_topic_timer_id"
|
|
ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD = "accepted_answer_post_id"
|
|
ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD = "enable_accepted_answers"
|
|
IS_ACCEPTED_ANSWER_CUSTOM_FIELD = "is_accepted_answer"
|
|
|
|
def self.accept_answer!(post, acting_user, topic: nil)
|
|
topic ||= post.topic
|
|
|
|
DistributedMutex.synchronize("discourse_solved_toggle_answer_#{topic.id}") do
|
|
accepted_id = topic.custom_fields[ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD].to_i
|
|
|
|
if accepted_id > 0
|
|
if p2 = Post.find_by(id: accepted_id)
|
|
p2.custom_fields.delete(IS_ACCEPTED_ANSWER_CUSTOM_FIELD)
|
|
p2.save!
|
|
|
|
if defined?(UserAction::SOLVED)
|
|
UserAction.where(action_type: UserAction::SOLVED, target_post_id: p2.id).destroy_all
|
|
end
|
|
end
|
|
end
|
|
|
|
post.custom_fields[IS_ACCEPTED_ANSWER_CUSTOM_FIELD] = "true"
|
|
topic.custom_fields[ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD] = post.id
|
|
|
|
if defined?(UserAction::SOLVED)
|
|
UserAction.log_action!(
|
|
action_type: UserAction::SOLVED,
|
|
user_id: post.user_id,
|
|
acting_user_id: acting_user.id,
|
|
target_post_id: post.id,
|
|
target_topic_id: post.topic_id,
|
|
)
|
|
end
|
|
|
|
notification_data = {
|
|
message: "solved.accepted_notification",
|
|
display_username: acting_user.username,
|
|
topic_title: topic.title,
|
|
title: "solved.notification.title",
|
|
}.to_json
|
|
|
|
unless acting_user.id == post.user_id
|
|
Notification.create!(
|
|
notification_type: Notification.types[:custom],
|
|
user_id: post.user_id,
|
|
topic_id: post.topic_id,
|
|
post_number: post.post_number,
|
|
data: notification_data,
|
|
)
|
|
end
|
|
|
|
if SiteSetting.notify_on_staff_accept_solved && acting_user.id != topic.user_id
|
|
Notification.create!(
|
|
notification_type: Notification.types[:custom],
|
|
user_id: topic.user_id,
|
|
topic_id: post.topic_id,
|
|
post_number: post.post_number,
|
|
data: notification_data,
|
|
)
|
|
end
|
|
|
|
auto_close_hours = 0
|
|
if topic&.category.present?
|
|
auto_close_hours = topic.category.custom_fields["solved_topics_auto_close_hours"].to_i
|
|
auto_close_hours = 175_200 if auto_close_hours > 175_200 # 20 years
|
|
end
|
|
|
|
auto_close_hours = SiteSetting.solved_topics_auto_close_hours if auto_close_hours == 0
|
|
|
|
if (auto_close_hours > 0) && !topic.closed
|
|
topic_timer =
|
|
topic.set_or_create_timer(
|
|
TopicTimer.types[:silent_close],
|
|
nil,
|
|
based_on_last_post: true,
|
|
duration_minutes: auto_close_hours * 60,
|
|
)
|
|
|
|
topic.custom_fields[AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD] = topic_timer.id
|
|
|
|
MessageBus.publish("/topic/#{topic.id}", reload_topic: true)
|
|
end
|
|
|
|
topic.save!
|
|
post.save!
|
|
|
|
if WebHook.active_web_hooks(:accepted_solution).exists?
|
|
payload = WebHook.generate_payload(:post, post)
|
|
WebHook.enqueue_solved_hooks(:accepted_solution, post, payload)
|
|
end
|
|
|
|
DiscourseEvent.trigger(:accepted_solution, post)
|
|
end
|
|
end
|
|
|
|
def self.unaccept_answer!(post, topic: nil)
|
|
topic ||= post.topic
|
|
|
|
DistributedMutex.synchronize("discourse_solved_toggle_answer_#{topic.id}") do
|
|
post.custom_fields.delete(IS_ACCEPTED_ANSWER_CUSTOM_FIELD)
|
|
topic.custom_fields.delete(ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD)
|
|
|
|
if timer_id = topic.custom_fields[AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD]
|
|
topic_timer = TopicTimer.find_by(id: timer_id)
|
|
topic_timer.destroy! if topic_timer
|
|
topic.custom_fields.delete(AUTO_CLOSE_TOPIC_TIMER_CUSTOM_FIELD)
|
|
end
|
|
|
|
topic.save!
|
|
post.save!
|
|
|
|
# TODO remove_action! does not allow for this type of interface
|
|
if defined?(UserAction::SOLVED)
|
|
UserAction.where(action_type: UserAction::SOLVED, target_post_id: post.id).destroy_all
|
|
end
|
|
|
|
# yank notification
|
|
notification =
|
|
Notification.find_by(
|
|
notification_type: Notification.types[:custom],
|
|
user_id: post.user_id,
|
|
topic_id: post.topic_id,
|
|
post_number: post.post_number,
|
|
)
|
|
|
|
notification.destroy! if notification
|
|
|
|
if WebHook.active_web_hooks(:unaccepted_solution).exists?
|
|
payload = WebHook.generate_payload(:post, post)
|
|
WebHook.enqueue_solved_hooks(:unaccepted_solution, post, payload)
|
|
end
|
|
|
|
DiscourseEvent.trigger(:unaccepted_solution, post)
|
|
end
|
|
end
|
|
end
|
|
|
|
require_dependency "application_controller"
|
|
|
|
class DiscourseSolved::AnswerController < ::ApplicationController
|
|
def accept
|
|
limit_accepts
|
|
|
|
post = Post.find(params[:id].to_i)
|
|
|
|
topic = post.topic
|
|
topic ||= Topic.with_deleted.find(post.topic_id) if guardian.is_staff?
|
|
|
|
guardian.ensure_can_accept_answer!(topic, post)
|
|
|
|
DiscourseSolved.accept_answer!(post, current_user, topic: topic)
|
|
|
|
render json: success_json
|
|
end
|
|
|
|
def unaccept
|
|
limit_accepts
|
|
|
|
post = Post.find(params[:id].to_i)
|
|
|
|
topic = post.topic
|
|
topic ||= Topic.with_deleted.find(post.topic_id) if guardian.is_staff?
|
|
|
|
guardian.ensure_can_accept_answer!(topic, post)
|
|
|
|
DiscourseSolved.unaccept_answer!(post, topic: topic)
|
|
|
|
render json: success_json
|
|
end
|
|
|
|
def limit_accepts
|
|
return if current_user.staff?
|
|
RateLimiter.new(nil, "accept-hr-#{current_user.id}", 20, 1.hour).performed!
|
|
RateLimiter.new(nil, "accept-min-#{current_user.id}", 4, 30.seconds).performed!
|
|
end
|
|
end
|
|
|
|
DiscourseSolved::Engine.routes.draw do
|
|
post "/accept" => "answer#accept"
|
|
post "/unaccept" => "answer#unaccept"
|
|
end
|
|
|
|
Discourse::Application.routes.append { mount ::DiscourseSolved::Engine, at: "solution" }
|
|
|
|
on(:post_destroyed) do |post|
|
|
if post.custom_fields[::DiscourseSolved::IS_ACCEPTED_ANSWER_CUSTOM_FIELD] == "true"
|
|
::DiscourseSolved.unaccept_answer!(post)
|
|
end
|
|
end
|
|
|
|
add_api_key_scope(
|
|
:solved,
|
|
{ answer: { actions: %w[discourse_solved/answer#accept discourse_solved/answer#unaccept] } },
|
|
)
|
|
|
|
topic_view_post_custom_fields_allowlister { [::DiscourseSolved::IS_ACCEPTED_ANSWER_CUSTOM_FIELD] }
|
|
|
|
def get_schema_text(post)
|
|
post.excerpt(nil, keep_onebox_body: true).presence ||
|
|
post.excerpt(nil, keep_onebox_body: true, keep_quotes: true)
|
|
end
|
|
|
|
def before_head_close_meta(controller)
|
|
return "" if !controller.instance_of? TopicsController
|
|
|
|
topic_view = controller.instance_variable_get(:@topic_view)
|
|
topic = topic_view&.topic
|
|
return "" if !topic
|
|
# note, we have canonicals so we only do this for page 1 at the moment
|
|
# it can get confusing to have this on every page and it should make page 1
|
|
# a bit more prominent + cut down on pointless work
|
|
|
|
return "" if SiteSetting.solved_add_schema_markup == "never"
|
|
|
|
allowed =
|
|
controller.guardian.allow_accepted_answers?(topic.category_id, topic.tags.pluck(:name))
|
|
return "" if !allowed
|
|
|
|
first_post = topic_view.posts&.first
|
|
return "" if first_post&.post_number != 1
|
|
|
|
question_json = {
|
|
"@type" => "Question",
|
|
"name" => topic.title,
|
|
"text" => get_schema_text(first_post),
|
|
"upvoteCount" => first_post.like_count,
|
|
"answerCount" => 0,
|
|
"datePublished" => topic.created_at,
|
|
"author" => {
|
|
"@type" => "Person",
|
|
"name" => topic.user&.username,
|
|
"url" => topic.user&.full_url,
|
|
},
|
|
}
|
|
|
|
if accepted_answer =
|
|
Post.find_by(
|
|
id: topic.custom_fields[::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD],
|
|
)
|
|
question_json["answerCount"] = 1
|
|
question_json[:acceptedAnswer] = {
|
|
"@type" => "Answer",
|
|
"text" => get_schema_text(accepted_answer),
|
|
"upvoteCount" => accepted_answer.like_count,
|
|
"datePublished" => accepted_answer.created_at,
|
|
"url" => accepted_answer.full_url,
|
|
"author" => {
|
|
"@type" => "Person",
|
|
"name" => accepted_answer.user&.username,
|
|
"url" => accepted_answer.user&.full_url,
|
|
},
|
|
}
|
|
else
|
|
return "" if SiteSetting.solved_add_schema_markup == "answered only"
|
|
end
|
|
|
|
[
|
|
'<script type="application/ld+json">',
|
|
MultiJson
|
|
.dump(
|
|
"@context" => "http://schema.org",
|
|
"@type" => "QAPage",
|
|
"name" => topic&.title,
|
|
"mainEntity" => question_json,
|
|
)
|
|
.gsub("</", "<\\/")
|
|
.html_safe,
|
|
"</script>",
|
|
].join("")
|
|
end
|
|
|
|
register_html_builder("server:before-head-close-crawler") do |controller|
|
|
before_head_close_meta(controller)
|
|
end
|
|
|
|
register_html_builder("server:before-head-close") do |controller|
|
|
before_head_close_meta(controller)
|
|
end
|
|
|
|
if Report.respond_to?(:add_report)
|
|
Report.add_report("accepted_solutions") do |report|
|
|
report.data = []
|
|
|
|
accepted_solutions =
|
|
TopicCustomField.where(name: ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD)
|
|
|
|
category_id, include_subcategories = report.add_category_filter
|
|
if category_id
|
|
if include_subcategories
|
|
accepted_solutions =
|
|
accepted_solutions.joins(:topic).where(
|
|
"topics.category_id IN (?)",
|
|
Category.subcategory_ids(category_id),
|
|
)
|
|
else
|
|
accepted_solutions =
|
|
accepted_solutions.joins(:topic).where("topics.category_id = ?", category_id)
|
|
end
|
|
end
|
|
|
|
accepted_solutions
|
|
.where("topic_custom_fields.created_at >= ?", report.start_date)
|
|
.where("topic_custom_fields.created_at <= ?", report.end_date)
|
|
.group("DATE(topic_custom_fields.created_at)")
|
|
.order("DATE(topic_custom_fields.created_at)")
|
|
.count
|
|
.each { |date, count| report.data << { x: date, y: count } }
|
|
report.total = accepted_solutions.count
|
|
report.prev30Days =
|
|
accepted_solutions
|
|
.where("topic_custom_fields.created_at >= ?", report.start_date - 30.days)
|
|
.where("topic_custom_fields.created_at <= ?", report.start_date)
|
|
.count
|
|
end
|
|
end
|
|
|
|
if respond_to?(:register_modifier)
|
|
register_modifier(:search_rank_sort_priorities) do |priorities, search|
|
|
if SiteSetting.prioritize_solved_topics_in_search
|
|
condition = <<~SQL
|
|
EXISTS
|
|
(
|
|
SELECT 1 FROM topic_custom_fields f
|
|
WHERE topics.id = f.topic_id
|
|
AND f.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}'
|
|
)
|
|
SQL
|
|
|
|
priorities.push([condition, 1.1])
|
|
else
|
|
priorities
|
|
end
|
|
end
|
|
end
|
|
|
|
if defined?(UserAction::SOLVED)
|
|
require_dependency "user_summary"
|
|
class ::UserSummary
|
|
def solved_count
|
|
UserAction.where(user: @user).where(action_type: UserAction::SOLVED).count
|
|
end
|
|
end
|
|
|
|
require_dependency "user_summary_serializer"
|
|
class ::UserSummarySerializer
|
|
attributes :solved_count
|
|
|
|
def solved_count
|
|
object.solved_count
|
|
end
|
|
end
|
|
end
|
|
|
|
class ::WebHook
|
|
def self.enqueue_solved_hooks(event, post, payload = nil)
|
|
if active_web_hooks(event).exists? && post.present?
|
|
payload ||= WebHook.generate_payload(:post, post)
|
|
|
|
WebHook.enqueue_hooks(
|
|
:solved,
|
|
event,
|
|
id: post.id,
|
|
category_id: post.topic&.category_id,
|
|
tag_ids: post.topic&.tags&.pluck(:id),
|
|
payload: payload,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
require_dependency "topic_view_serializer"
|
|
class ::TopicViewSerializer
|
|
attributes :accepted_answer
|
|
|
|
def include_accepted_answer?
|
|
accepted_answer_post_id
|
|
end
|
|
|
|
def accepted_answer
|
|
if info = accepted_answer_post_info
|
|
{ post_number: info[0], username: info[1], excerpt: info[2], name: info[3] }
|
|
end
|
|
end
|
|
|
|
def accepted_answer_post_info
|
|
post_info =
|
|
if post = object.posts.find { |p| p.post_number == accepted_answer_post_id }
|
|
[post.post_number, post.user.username, post.cooked, post.user.name]
|
|
else
|
|
Post
|
|
.where(id: accepted_answer_post_id, topic_id: object.topic.id)
|
|
.joins(:user)
|
|
.pluck("post_number", "username", "cooked", "name")
|
|
.first
|
|
end
|
|
|
|
if post_info
|
|
post_info[2] = if SiteSetting.solved_quote_length > 0
|
|
PrettyText.excerpt(post_info[2], SiteSetting.solved_quote_length, keep_emoji_images: true)
|
|
else
|
|
nil
|
|
end
|
|
|
|
post_info[3] = nil if !SiteSetting.enable_names || !SiteSetting.display_name_on_posts
|
|
|
|
post_info
|
|
end
|
|
end
|
|
|
|
def accepted_answer_post_id
|
|
id = object.topic.custom_fields[::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD]
|
|
# a bit messy but race conditions can give us an array here, avoid
|
|
begin
|
|
id && id.to_i
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
class ::Category
|
|
after_save :reset_accepted_cache
|
|
|
|
protected
|
|
|
|
def reset_accepted_cache
|
|
::DiscourseSolved::AcceptedAnswerCache.reset_accepted_answer_cache
|
|
end
|
|
end
|
|
|
|
require_dependency "post_serializer"
|
|
|
|
class ::PostSerializer
|
|
attributes :can_accept_answer, :can_unaccept_answer, :accepted_answer, :topic_accepted_answer
|
|
|
|
def can_accept_answer
|
|
scope.can_accept_answer?(topic, object) && object.post_number > 1 && !accepted_answer
|
|
end
|
|
|
|
def can_unaccept_answer
|
|
scope.can_accept_answer?(topic, object) && accepted_answer
|
|
end
|
|
|
|
def accepted_answer
|
|
post_custom_fields[::DiscourseSolved::IS_ACCEPTED_ANSWER_CUSTOM_FIELD] == "true"
|
|
end
|
|
|
|
def topic_accepted_answer
|
|
topic&.custom_fields&.[](::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD).present?
|
|
end
|
|
|
|
def topic
|
|
topic_view&.topic || object.topic
|
|
end
|
|
end
|
|
|
|
require_dependency "search"
|
|
|
|
#TODO Remove when plugin is 1.0
|
|
if Search.respond_to? :advanced_filter
|
|
Search.advanced_filter(/status:solved/) do |posts|
|
|
posts.where(
|
|
"topics.id IN (
|
|
SELECT tc.topic_id
|
|
FROM topic_custom_fields tc
|
|
WHERE tc.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' AND
|
|
tc.value IS NOT NULL
|
|
)",
|
|
)
|
|
end
|
|
|
|
Search.advanced_filter(/status:unsolved/) do |posts|
|
|
if SiteSetting.allow_solved_on_all_topics
|
|
posts.where(
|
|
"topics.id NOT IN (
|
|
SELECT tc.topic_id
|
|
FROM topic_custom_fields tc
|
|
WHERE tc.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' AND
|
|
tc.value IS NOT NULL
|
|
)",
|
|
)
|
|
else
|
|
posts.where(
|
|
"topics.id NOT IN (
|
|
SELECT tc.topic_id
|
|
FROM topic_custom_fields tc
|
|
WHERE tc.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' AND
|
|
tc.value IS NOT NULL
|
|
) AND topics.id IN (
|
|
SELECT top.id
|
|
FROM topics top
|
|
INNER JOIN category_custom_fields cc
|
|
ON top.category_id = cc.category_id
|
|
WHERE cc.name = '#{::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD}' AND
|
|
cc.value = 'true'
|
|
)",
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
if Discourse.has_needed_version?(Discourse::VERSION::STRING, "1.8.0.beta6")
|
|
require_dependency "topic_query"
|
|
|
|
TopicQuery.add_custom_filter(:solved) do |results, topic_query|
|
|
if topic_query.options[:solved] == "yes"
|
|
results =
|
|
results.where(
|
|
"topics.id IN (
|
|
SELECT tc.topic_id
|
|
FROM topic_custom_fields tc
|
|
WHERE tc.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' AND
|
|
tc.value IS NOT NULL
|
|
)",
|
|
)
|
|
elsif topic_query.options[:solved] == "no"
|
|
results =
|
|
results.where(
|
|
"topics.id NOT IN (
|
|
SELECT tc.topic_id
|
|
FROM topic_custom_fields tc
|
|
WHERE tc.name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}' AND
|
|
tc.value IS NOT NULL
|
|
)",
|
|
)
|
|
end
|
|
results
|
|
end
|
|
end
|
|
|
|
require_dependency "topic_list_item_serializer"
|
|
require_dependency "search_topic_list_item_serializer"
|
|
require_dependency "suggested_topic_serializer"
|
|
require_dependency "user_summary_serializer"
|
|
|
|
class ::TopicListItemSerializer
|
|
include TopicAnswerMixin
|
|
end
|
|
|
|
class ::SearchTopicListItemSerializer
|
|
include TopicAnswerMixin
|
|
end
|
|
|
|
class ::SuggestedTopicSerializer
|
|
include TopicAnswerMixin
|
|
end
|
|
|
|
class ::UserSummarySerializer::TopicSerializer
|
|
include TopicAnswerMixin
|
|
end
|
|
|
|
class ::ListableTopicSerializer
|
|
include TopicAnswerMixin
|
|
end
|
|
|
|
if TopicList.respond_to? :preloaded_custom_fields
|
|
TopicList.preloaded_custom_fields << ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD
|
|
end
|
|
if Site.respond_to? :preloaded_category_custom_fields
|
|
Site.preloaded_category_custom_fields << ::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD
|
|
end
|
|
if Search.respond_to? :preloaded_topic_custom_fields
|
|
Search.preloaded_topic_custom_fields << ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD
|
|
end
|
|
|
|
if CategoryList.respond_to?(:preloaded_topic_custom_fields)
|
|
CategoryList.preloaded_topic_custom_fields << ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD
|
|
end
|
|
|
|
on(:filter_auto_bump_topics) { |_category, filters| filters.push(->(r) { r.where(<<~SQL) }) }
|
|
NOT EXISTS(
|
|
SELECT 1 FROM topic_custom_fields
|
|
WHERE topic_id = topics.id
|
|
AND name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}'
|
|
AND value IS NOT NULL
|
|
)
|
|
SQL
|
|
|
|
on(:before_post_publish_changes) do |post_changes, topic_changes, options|
|
|
category_id_changes = topic_changes.diff["category_id"].to_a
|
|
tag_changes = topic_changes.diff["tags"].to_a
|
|
|
|
old_allowed = Guardian.new.allow_accepted_answers?(category_id_changes[0], tag_changes[0])
|
|
new_allowed = Guardian.new.allow_accepted_answers?(category_id_changes[1], tag_changes[1])
|
|
|
|
options[:refresh_stream] = true if old_allowed != new_allowed
|
|
end
|
|
|
|
on(:after_populate_dev_records) do |records, type|
|
|
next unless SiteSetting.solved_enabled
|
|
|
|
if type == :category
|
|
next if SiteSetting.allow_solved_on_all_topics
|
|
|
|
solved_category =
|
|
DiscourseDev::Record.random(
|
|
Category.where(read_restricted: false, id: records.pluck(:id), parent_category_id: nil),
|
|
)
|
|
CategoryCustomField.create!(
|
|
category_id: solved_category.id,
|
|
name: ::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD,
|
|
value: "true",
|
|
)
|
|
puts "discourse-solved enabled on category '#{solved_category.name}' (#{solved_category.id})."
|
|
elsif type == :topic
|
|
topics = Topic.where(id: records.pluck(:id))
|
|
|
|
unless SiteSetting.allow_solved_on_all_topics
|
|
solved_category_id =
|
|
CategoryCustomField
|
|
.where(name: ::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD, value: "true")
|
|
.first
|
|
.category_id
|
|
|
|
unless topics.exists?(category_id: solved_category_id)
|
|
topics.last.update(category_id: solved_category_id)
|
|
end
|
|
|
|
topics = topics.where(category_id: solved_category_id)
|
|
end
|
|
|
|
solved_topic = DiscourseDev::Record.random(topics)
|
|
post = nil
|
|
|
|
if solved_topic.posts_count > 1
|
|
post = DiscourseDev::Record.random(solved_topic.posts.where.not(post_number: 1))
|
|
else
|
|
post = DiscourseDev::Post.new(solved_topic, 1).create!
|
|
end
|
|
|
|
DiscourseSolved.accept_answer!(post, post.topic.user, topic: post.topic)
|
|
end
|
|
end
|
|
|
|
query =
|
|
"
|
|
WITH x AS (SELECT
|
|
u.id user_id,
|
|
COUNT(DISTINCT ua.id) AS solutions
|
|
FROM users AS u
|
|
LEFT OUTER JOIN user_actions AS ua ON ua.user_id = u.id AND ua.action_type = #{UserAction::SOLVED} AND COALESCE(ua.created_at, :since) > :since
|
|
WHERE u.active
|
|
AND u.silenced_till IS NULL
|
|
AND u.id > 0
|
|
GROUP BY u.id
|
|
)
|
|
UPDATE directory_items di SET
|
|
solutions = x.solutions
|
|
FROM x
|
|
WHERE x.user_id = di.user_id
|
|
AND di.period_type = :period_type
|
|
AND di.solutions <> x.solutions
|
|
"
|
|
add_directory_column("solutions", query: query) if respond_to?(:add_directory_column)
|
|
|
|
add_to_class(:composer_messages_finder, :check_topic_is_solved) do
|
|
return if !SiteSetting.solved_enabled || SiteSetting.disable_solved_education_message
|
|
return if !replying? || @topic.blank? || @topic.private_message?
|
|
return if @topic.custom_fields[::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD].blank?
|
|
|
|
{
|
|
id: "solved_topic",
|
|
templateName: "education",
|
|
wait_for_typing: true,
|
|
extraClass: "education-message",
|
|
hide_if_whisper: true,
|
|
body: PrettyText.cook(I18n.t("education.topic_is_solved", base_url: Discourse.base_url)),
|
|
}
|
|
end
|
|
|
|
if defined?(UserAction::SOLVED)
|
|
add_to_serializer(:user_card, :accepted_answers) do
|
|
UserAction.where(user_id: object.id).where(action_type: UserAction::SOLVED).count
|
|
end
|
|
end
|
|
|
|
if respond_to?(:register_topic_list_preload_user_ids)
|
|
class ::Topic
|
|
attr_accessor :accepted_answer_user_id
|
|
end
|
|
|
|
register_topic_list_preload_user_ids do |topics, user_ids, topic_list|
|
|
answer_post_ids =
|
|
TopicCustomField
|
|
.select("value::INTEGER")
|
|
.where(name: ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD)
|
|
.where(topic_id: topics.map(&:id))
|
|
answer_user_ids = Post.where(id: answer_post_ids).pluck(:topic_id, :user_id).to_h
|
|
topics.each { |topic| topic.accepted_answer_user_id = answer_user_ids[topic.id] }
|
|
user_ids.concat(answer_user_ids.values)
|
|
end
|
|
|
|
module AddSolvedToTopicPostersSummary
|
|
def descriptions_by_id
|
|
if !defined?(@descriptions_by_id)
|
|
super(ids: old_user_ids)
|
|
|
|
if id = topic.accepted_answer_user_id
|
|
@descriptions_by_id[id] ||= []
|
|
@descriptions_by_id[id] << I18n.t(:accepted_answer)
|
|
end
|
|
end
|
|
|
|
super
|
|
end
|
|
|
|
def last_poster_is_topic_creator?
|
|
super || topic.accepted_answer_user_id == topic.last_post_user_id
|
|
end
|
|
|
|
def user_ids
|
|
if id = topic.accepted_answer_user_id
|
|
super.insert(1, id)
|
|
else
|
|
super
|
|
end
|
|
end
|
|
end
|
|
|
|
TopicPostersSummary.class_eval do
|
|
alias old_user_ids user_ids
|
|
|
|
prepend AddSolvedToTopicPostersSummary
|
|
end
|
|
end
|
|
|
|
if defined?(DiscourseAutomation)
|
|
if respond_to?(:add_triggerable_to_scriptable)
|
|
on(:accepted_solution) do |post|
|
|
# testing directly automation is prone to issues
|
|
# we prefer to abstract logic in service object and test this
|
|
next if Rails.env.test?
|
|
|
|
name = "first_accepted_solution"
|
|
DiscourseAutomation::Automation
|
|
.where(trigger: name, enabled: true)
|
|
.find_each do |automation|
|
|
maximum_trust_level = automation.trigger_field("maximum_trust_level")&.dig("value")
|
|
if FirstAcceptedPostSolutionValidator.check(post, trust_level: maximum_trust_level)
|
|
automation.trigger!(
|
|
"kind" => name,
|
|
"accepted_post_id" => post.id,
|
|
"usernames" => [post.user.username],
|
|
"placeholders" => {
|
|
"post_url" => Discourse.base_url + post.url,
|
|
},
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
TRUST_LEVELS = [
|
|
{
|
|
id: 1,
|
|
name: "discourse_automation.triggerables.first_accepted_solution.max_trust_level.tl1",
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "discourse_automation.triggerables.first_accepted_solution.max_trust_level.tl2",
|
|
},
|
|
{
|
|
id: 3,
|
|
name: "discourse_automation.triggerables.first_accepted_solution.max_trust_level.tl3",
|
|
},
|
|
{
|
|
id: 4,
|
|
name: "discourse_automation.triggerables.first_accepted_solution.max_trust_level.tl4",
|
|
},
|
|
{
|
|
id: "any",
|
|
name: "discourse_automation.triggerables.first_accepted_solution.max_trust_level.any",
|
|
},
|
|
]
|
|
|
|
add_triggerable_to_scriptable(:first_accepted_solution, :send_pms)
|
|
|
|
DiscourseAutomation::Triggerable.add(:first_accepted_solution) do
|
|
placeholder :post_url
|
|
|
|
field :maximum_trust_level,
|
|
component: :choices,
|
|
extra: {
|
|
content: TRUST_LEVELS,
|
|
},
|
|
required: true
|
|
end
|
|
end
|
|
end
|
|
end
|