discourse-solved/plugin.rb
Arpit Jalan bffc46858a
FEATURE: solved topic auto close setting per category (#233)
* FEATURE: solved topic auto close setting per category

This commit adds per category "solved topics auto close hours" setting. The plugin would use the existing "solved topics auto close hours" setting, except if there was a setting for the relevant category in which case that would take precedence.

* minor changes per feedback
2023-04-19 19:44:33 +05:30

856 lines
26 KiB
Ruby

# frozen_string_literal: true
# name: discourse-solved
# about: Add a solved button to answers on Discourse
# version: 0.1
# authors: Sam Saffron
# url: https://github.com/discourse/discourse-solved
# transpile_js: true
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".freeze
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/serializers/concerns/topic_answer_mixin.rb
].each { |path| load File.expand_path(path, __FILE__) }
skip_db = defined?(GlobalSetting.skip_db?) && GlobalSetting.skip_db?
# 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".freeze
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"].to_i
if accepted_id > 0
if p2 = Post.find_by(id: accepted_id)
p2.custom_fields.delete("is_accepted_answer")
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"] = "true"
topic.custom_fields["accepted_answer_post_id"] = 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(:solved).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")
topic.custom_fields.delete("accepted_answer_post_id")
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(:solved).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" }
add_api_key_scope(
:solved,
{ answer: { actions: %w[discourse_solved/answer#accept discourse_solved/answer#unaccept] } },
)
topic_view_post_custom_fields_allowlister { ["is_accepted_answer"] }
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,
"dateCreated" => topic.created_at,
"author" => {
"@type" => "Person",
"name" => topic.user&.name,
},
}
if accepted_answer = Post.find_by(id: topic.custom_fields["accepted_answer_post_id"])
question_json["answerCount"] = 1
question_json[:acceptedAnswer] = {
"@type" => "Answer",
"text" => get_schema_text(accepted_answer),
"upvoteCount" => accepted_answer.like_count,
"dateCreated" => accepted_answer.created_at,
"url" => accepted_answer.full_url,
"author" => {
"@type" => "Person",
"name" => accepted_answer.user&.username,
},
}
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: "accepted_answer_post_id")
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 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("solved").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] }
end
end
def accepted_answer_post_info
# TODO: we may already have it in the stream ... so bypass query here
postInfo =
Post
.where(id: accepted_answer_post_id, topic_id: object.topic.id)
.joins(:user)
.pluck("post_number", "username", "cooked")
.first
if postInfo
postInfo[2] = if SiteSetting.solved_quote_length > 0
PrettyText.excerpt(postInfo[2], SiteSetting.solved_quote_length, keep_emoji_images: true)
else
nil
end
postInfo
end
end
def accepted_answer_post_id
id = object.topic.custom_fields["accepted_answer_post_id"]
# 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
::Guardian.reset_accepted_answer_cache
end
end
class ::Guardian
@@allowed_accepted_cache = DistributedCache.new("allowed_accepted")
def self.reset_accepted_answer_cache
@@allowed_accepted_cache["allowed"] = begin
Set.new(
CategoryCustomField.where(name: "enable_accepted_answers", value: "true").pluck(
:category_id,
),
)
end
end
def allow_accepted_answers?(category_id, tag_names = [])
return true if SiteSetting.allow_solved_on_all_topics
if SiteSetting.enable_solved_tags.present? && tag_names.present?
allowed_tags = SiteSetting.enable_solved_tags.split("|")
is_allowed = (tag_names & allowed_tags).present?
return true if is_allowed
end
return false if category_id.blank?
self.class.reset_accepted_answer_cache unless @@allowed_accepted_cache["allowed"]
@@allowed_accepted_cache["allowed"].include?(category_id)
end
def can_accept_answer?(topic, post)
return false if !authenticated?
return false if !topic || !post || post.whisper?
return false if !allow_accepted_answers?(topic.category_id, topic.tags.map(&:name))
return true if is_staff?
return true if current_user.trust_level >= SiteSetting.accept_all_solutions_trust_level
if respond_to? :can_perform_action_available_to_group_moderators?
return true if can_perform_action_available_to_group_moderators?(topic)
end
topic.user_id == current_user.id && !topic.closed && SiteSetting.accept_solutions_topic_author
end
end
require_dependency "post_serializer"
class ::PostSerializer
attributes :can_accept_answer, :can_unaccept_answer, :accepted_answer, :topic_accepted_answer
def can_accept_answer
if topic = (topic_view && topic_view.topic) || object.topic
return scope.can_accept_answer?(topic, object) && object.post_number > 1 && !accepted_answer
end
false
end
def can_unaccept_answer
if topic = (topic_view && topic_view.topic) || object.topic
scope.can_accept_answer?(topic, object) &&
(post_custom_fields["is_accepted_answer"] == "true")
end
end
def accepted_answer
post_custom_fields["is_accepted_answer"] == "true"
end
def topic_accepted_answer
if topic = (topic_view && topic_view.topic) || object.topic
topic.custom_fields["accepted_answer_post_id"].present?
end
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 = 'accepted_answer_post_id' AND
tc.value IS NOT NULL
)",
)
end
Search.advanced_filter(/status:unsolved/) do |posts|
posts.where(
"topics.id NOT IN (
SELECT tc.topic_id
FROM topic_custom_fields tc
WHERE tc.name = 'accepted_answer_post_id' AND
tc.value IS NOT NULL
)",
)
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 = 'accepted_answer_post_id' 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 = 'accepted_answer_post_id' 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 << "accepted_answer_post_id"
end
if Site.respond_to? :preloaded_category_custom_fields
Site.preloaded_category_custom_fields << "enable_accepted_answers"
end
if Search.respond_to? :preloaded_topic_custom_fields
Search.preloaded_topic_custom_fields << "accepted_answer_post_id"
end
if CategoryList.respond_to?(:preloaded_topic_custom_fields)
CategoryList.preloaded_topic_custom_fields << "accepted_answer_post_id"
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 = 'accepted_answer_post_id'
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: "enable_accepted_answers",
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: "enable_accepted_answers", 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["accepted_answer_post_id"].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: "accepted_answer_post_id")
.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