mirror of
https://github.com/discourse/discourse-solved.git
synced 2026-10-04 14:13:13 -04:00
DEV: Autoload and segregate features to prep for migration (#341)
This commit autoloads plugin files, and also extracts features into their own modules. - `plugin.rb` is smaller - external plugins like discourse-automation and discourse-assign have their own entrypoints - solved filters as well
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAssign
|
||||
class EntryPoint
|
||||
def self.inject(plugin)
|
||||
plugin.register_modifier(:assigns_reminder_assigned_topics_query) do |query|
|
||||
next query if !SiteSetting.ignore_solved_topics_in_assigned_reminder
|
||||
query.where.not(
|
||||
id:
|
||||
TopicCustomField.where(
|
||||
name: ::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD,
|
||||
).pluck(:topic_id),
|
||||
)
|
||||
end
|
||||
|
||||
plugin.register_modifier(:assigned_count_for_user_query) do |query, user|
|
||||
next query if !SiteSetting.ignore_solved_topics_in_assigned_reminder
|
||||
next query if SiteSetting.assignment_status_on_solve.blank?
|
||||
query.where.not(status: SiteSetting.assignment_status_on_solve)
|
||||
end
|
||||
|
||||
plugin.on(:accepted_solution) do |post|
|
||||
next if SiteSetting.assignment_status_on_solve.blank?
|
||||
assignments = Assignment.includes(:target).where(topic: post.topic)
|
||||
assignments.each do |assignment|
|
||||
assigned_user = User.find_by(id: assignment.assigned_to_id)
|
||||
Assigner.new(assignment.target, assigned_user).assign(
|
||||
assigned_user,
|
||||
status: SiteSetting.assignment_status_on_solve,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
plugin.on(:unaccepted_solution) do |post|
|
||||
next if SiteSetting.assignment_status_on_unsolve.blank?
|
||||
assignments = Assignment.includes(:target).where(topic: post.topic)
|
||||
assignments.each do |assignment|
|
||||
assigned_user = User.find_by(id: assignment.assigned_to_id)
|
||||
Assigner.new(assignment.target, assigned_user).assign(
|
||||
assigned_user,
|
||||
status: SiteSetting.assignment_status_on_unsolve,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,69 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAutomation
|
||||
class EntryPoint
|
||||
def self.inject(plugin)
|
||||
plugin.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
|
||||
|
||||
plugin.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: [
|
||||
{
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
required: true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseDev
|
||||
class DiscourseSolved
|
||||
def self.populate(plugin)
|
||||
plugin.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
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSolved
|
||||
class AcceptedAnswerCache
|
||||
@@allowed_accepted_cache = DistributedCache.new("allowed_accepted")
|
||||
|
||||
def self.reset_accepted_answer_cache
|
||||
@@allowed_accepted_cache["allowed"] = begin
|
||||
Set.new(
|
||||
CategoryCustomField.where(
|
||||
name: ::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD,
|
||||
value: "true",
|
||||
).pluck(:category_id),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def self.allowed
|
||||
@@allowed_accepted_cache["allowed"]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class DiscourseSolved::BeforeHeadClose
|
||||
attr_reader :controller
|
||||
|
||||
def initialize(controller)
|
||||
@controller = controller
|
||||
end
|
||||
|
||||
def html
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def get_schema_text(post)
|
||||
post.excerpt(nil, keep_onebox_body: true).presence ||
|
||||
post.excerpt(nil, keep_onebox_body: true, keep_quotes: true)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSolved::CategoryExtension
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
prepended { after_save :reset_accepted_cache, if: -> { SiteSetting.solved_enabled? } }
|
||||
|
||||
private
|
||||
|
||||
def reset_accepted_cache
|
||||
::DiscourseSolved::AcceptedAnswerCache.reset_accepted_answer_cache
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ::DiscourseSolved
|
||||
class Engine < ::Rails::Engine
|
||||
engine_name PLUGIN_NAME
|
||||
isolate_namespace DiscourseSolved
|
||||
config.autoload_paths << File.join(config.root, "lib")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSolved
|
||||
class FirstAcceptedPostSolutionValidator
|
||||
def self.check(post, trust_level:)
|
||||
return false if post.archetype != Archetype.default
|
||||
return false if !post&.user&.human?
|
||||
return true if trust_level == "any"
|
||||
|
||||
return false if TrustLevel.compare(post&.user&.trust_level, trust_level.to_i)
|
||||
|
||||
if !UserAction.where(user_id: post&.user_id, action_type: UserAction::SOLVED).exists?
|
||||
return true
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSolved
|
||||
module GuardianExtensions
|
||||
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?
|
||||
if !::DiscourseSolved::AcceptedAnswerCache.allowed
|
||||
::DiscourseSolved::AcceptedAnswerCache.reset_accepted_answer_cache
|
||||
end
|
||||
::DiscourseSolved::AcceptedAnswerCache.allowed.include?(category_id)
|
||||
end
|
||||
|
||||
def can_accept_answer?(topic, post)
|
||||
return false if !authenticated?
|
||||
return false if !topic || topic.private_message? || !post || post.whisper?
|
||||
return false if !allow_accepted_answers?(topic.category_id, topic.tags.map(&:name))
|
||||
|
||||
return true if is_staff?
|
||||
if current_user.in_any_groups?(SiteSetting.accept_all_solutions_allowed_groups_map)
|
||||
return true
|
||||
end
|
||||
return true if is_category_group_moderator?(topic.category)
|
||||
|
||||
topic.user_id == current_user.id && !topic.closed && SiteSetting.accept_solutions_topic_author
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSolved::PostSerializerExtension
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def topic
|
||||
topic_view&.topic || object.topic
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,69 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSolved
|
||||
class RegisterFilters
|
||||
def self.register(plugin)
|
||||
solved_callback = ->(scope) do
|
||||
sql = <<~SQL
|
||||
topics.id IN (
|
||||
SELECT topic_id
|
||||
FROM topic_custom_fields
|
||||
WHERE name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}'
|
||||
AND value IS NOT NULL
|
||||
)
|
||||
SQL
|
||||
|
||||
scope.where(sql).where("topics.archetype <> ?", Archetype.private_message)
|
||||
end
|
||||
unsolved_callback = ->(scope) do
|
||||
scope = scope.where <<~SQL
|
||||
topics.id NOT IN (
|
||||
SELECT topic_id
|
||||
FROM topic_custom_fields
|
||||
WHERE name = '#{::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD}'
|
||||
AND value IS NOT NULL
|
||||
)
|
||||
SQL
|
||||
|
||||
if !SiteSetting.allow_solved_on_all_topics
|
||||
tag_ids = Tag.where(name: SiteSetting.enable_solved_tags.split("|")).pluck(:id)
|
||||
|
||||
scope = scope.where <<~SQL, tag_ids
|
||||
topics.id IN (
|
||||
SELECT t.id
|
||||
FROM topics t
|
||||
JOIN category_custom_fields cc
|
||||
ON t.category_id = cc.category_id
|
||||
AND cc.name = '#{::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD}'
|
||||
AND cc.value = 'true'
|
||||
)
|
||||
OR
|
||||
topics.id IN (
|
||||
SELECT topic_id
|
||||
FROM topic_tags
|
||||
WHERE tag_id IN (?)
|
||||
)
|
||||
SQL
|
||||
end
|
||||
|
||||
scope.where("topics.archetype <> ?", Archetype.private_message)
|
||||
end
|
||||
|
||||
plugin.register_custom_filter_by_status("solved", &solved_callback)
|
||||
plugin.register_custom_filter_by_status("unsolved", &unsolved_callback)
|
||||
|
||||
plugin.register_search_advanced_filter(/status:solved/, &solved_callback)
|
||||
plugin.register_search_advanced_filter(/status:unsolved/, &unsolved_callback)
|
||||
|
||||
TopicQuery.add_custom_filter(:solved) do |results, topic_query|
|
||||
if topic_query.options[:solved] == "yes"
|
||||
solved_callback.call(results)
|
||||
elsif topic_query.options[:solved] == "no"
|
||||
unsolved_callback.call(results)
|
||||
else
|
||||
results
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSolved::TopicPostersSummaryExtension
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
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
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSolved::TopicViewSerializerExtension
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
prepended { attributes :accepted_answer }
|
||||
|
||||
def include_accepted_answer?
|
||||
SiteSetting.solved_enabled? && 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
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSolved::UserSummaryExtension
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def solved_count
|
||||
UserAction.where(user: @user).where(action_type: UserAction::SOLVED).count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSolved::WebHookExtension
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def 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
|
||||
end
|
||||
Reference in New Issue
Block a user