discourse-solved/plugin.rb

356 lines
12 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
# name: discourse-solved
# about: Allows users to accept solutions on topics in designated categories.
# meta_topic_id: 30155
2015-05-19 15:45:19 +10:00
# version: 0.1
# authors: Sam Saffron
2017-04-26 00:40:03 -04:00
# url: https://github.com/discourse/discourse-solved
2015-05-19 15:45:19 +10:00
2017-02-02 12:20:01 -05:00
enabled_site_setting :solved_enabled
register_svg_icon "far-square-check"
register_svg_icon "square-check"
register_svg_icon "far-square"
2018-11-07 21:08:46 -05:00
register_asset "stylesheets/solutions.scss"
register_asset "stylesheets/mobile/solutions.scss", :mobile
2015-05-19 15:45:19 +10:00
module ::DiscourseSolved
PLUGIN_NAME = "discourse-solved"
ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD = "enable_accepted_answers"
end
require_relative "lib/discourse_solved/engine.rb"
after_initialize do
2024-03-05 12:14:32 +01:00
SeedFu.fixture_paths << Rails.root.join("plugins", "discourse-solved", "db", "fixtures").to_s
module ::DiscourseSolved
def self.accept_answer!(post, acting_user, topic: nil)
topic ||= post.topic
2015-05-19 15:45:19 +10:00
DistributedMutex.synchronize("discourse_solved_toggle_answer_#{topic.id}") do
solved = topic.solved
ActiveRecord::Base.transaction do
if previous_accepted_post_id = solved&.answer_post_id
UserAction.where(
action_type: UserAction::SOLVED,
target_post_id: previous_accepted_post_id,
).destroy_all
solved.destroy!
else
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
solved =
DiscourseSolved::SolvedTopic.new(topic:, answer_post: post, accepter: acting_user)
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: {
message: "solved.accepted_notification",
display_username: acting_user.username,
topic_title: topic.title,
title: "solved.notification.title",
}.to_json,
)
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: {
message: "solved.accepted_notification",
display_username: acting_user.username,
topic_title: topic.title,
title: "solved.notification.title",
}.to_json,
)
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,
)
solved.topic_timer = topic_timer
MessageBus.publish("/topic/#{topic.id}", reload_topic: true)
end
solved.save!
end
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
2015-05-19 15:45:19 +10:00
end
def self.unaccept_answer!(post, topic: nil)
topic ||= post.topic
topic ||= Topic.unscoped.find_by(id: post.topic_id)
return if topic.nil?
return if topic.solved.nil?
DistributedMutex.synchronize("discourse_solved_toggle_answer_#{topic.id}") do
solved = topic.solved
ActiveRecord::Base.transaction do
UserAction.where(action_type: UserAction::SOLVED, target_post_id: post.id).destroy_all
Notification.find_by(
notification_type: Notification.types[:custom],
user_id: post.user_id,
topic_id: post.topic_id,
post_number: post.post_number,
)&.destroy!
solved.destroy!
end
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)
FEATURE: Publish WebHook event when solving/unsolving (#85) * FEATURE: Publish WebHook event when solving/unsolving This feature will publish a post edit webhook event whenever a solution is accepted or unaccepted. I went ahead and used the existing post-edit webhook because all the post custom fields for the solved plugin are already included in the post-edit serializer. * Create Solved Event Webhook This commit adds a solved event webhook that will only trigger when an answer has been marked as accepted or unaccepted. It uses 100 as the webhook ID. This way any new webhooks in core can keep using lower numbers like 11, 12, 13, but plugins can use 101, 102, etc. * Removed functionality that was added to core This [PR][1] to discourse core adds what what removed in this commit. It is better to have this logic in core so that it is discoverable and future webhooks won't end up accidentally using the same ID. [1]: https://github.com/discourse/discourse/pull/9110 * UX: Add "solved" status filter in advanced search page. And rename `in:solved` to `status:solved`. * FEATURE: Publish WebHook event when solving/unsolving This feature will publish a post edit webhook event whenever a solution is accepted or unaccepted. I went ahead and used the existing post-edit webhook because all the post custom fields for the solved plugin are already included in the post-edit serializer. * Create Solved Event Webhook This commit adds a solved event webhook that will only trigger when an answer has been marked as accepted or unaccepted. It uses 100 as the webhook ID. This way any new webhooks in core can keep using lower numbers like 11, 12, 13, but plugins can use 101, 102, etc. * Removed functionality that was added to core This [PR][1] to discourse core adds what what removed in this commit. It is better to have this logic in core so that it is discoverable and future webhooks won't end up accidentally using the same ID. [1]: https://github.com/discourse/discourse/pull/9110 Co-authored-by: Vinoth Kannan <vinothkannan@vinkas.com>
2020-03-06 11:28:29 -07:00
end
end
2024-03-05 12:14:32 +01:00
def self.skip_db?
defined?(GlobalSetting.skip_db?) && GlobalSetting.skip_db?
end
2024-03-05 12:14:32 +01:00
end
reloadable_patch do
2024-03-05 12:14:32 +01:00
::Guardian.prepend(DiscourseSolved::GuardianExtensions)
::WebHook.prepend(DiscourseSolved::WebHookExtension)
::TopicViewSerializer.prepend(DiscourseSolved::TopicViewSerializerExtension)
::Topic.prepend(DiscourseSolved::TopicExtension)
2024-03-05 12:14:32 +01:00
::Category.prepend(DiscourseSolved::CategoryExtension)
::PostSerializer.prepend(DiscourseSolved::PostSerializerExtension)
::UserSummary.prepend(DiscourseSolved::UserSummaryExtension)
::Topic.attr_accessor(:accepted_answer_user_id)
::TopicPostersSummary.alias_method(:old_user_ids, :user_ids)
::TopicPostersSummary.prepend(DiscourseSolved::TopicPostersSummaryExtension)
2024-03-05 12:14:32 +01:00
[
::TopicListItemSerializer,
::SearchTopicListItemSerializer,
::SuggestedTopicSerializer,
::UserSummarySerializer::TopicSerializer,
::ListableTopicSerializer,
].each { |klass| klass.include(DiscourseSolved::TopicAnswerMixin) }
2024-03-05 12:14:32 +01:00
end
register_category_list_topics_preloader_associations(:solved) if SiteSetting.solved_enabled
register_topic_preloader_associations(:solved) if SiteSetting.solved_enabled
Search.custom_topic_eager_load { [:solved] } if SiteSetting.solved_enabled
Site.preloaded_category_custom_fields << ::DiscourseSolved::ENABLE_ACCEPTED_ANSWERS_CUSTOM_FIELD
add_api_key_scope(
:solved,
{ answer: { actions: %w[discourse_solved/answer#accept discourse_solved/answer#unaccept] } },
)
register_html_builder("server:before-head-close-crawler") do |controller|
2024-03-05 12:14:32 +01:00
DiscourseSolved::BeforeHeadClose.new(controller).html
end
register_html_builder("server:before-head-close") do |controller|
2024-03-05 12:14:32 +01:00
DiscourseSolved::BeforeHeadClose.new(controller).html
end
Report.add_report("accepted_solutions") do |report|
report.data = []
accepted_solutions =
DiscourseSolved::SolvedTopic.joins(:topic).where(
"topics.archetype <> ?",
Archetype.private_message,
)
category_id, include_subcategories = report.add_category_filter
if category_id
if include_subcategories
accepted_solutions =
accepted_solutions.where(
"topics.category_id IN (?)",
Category.subcategory_ids(category_id),
)
else
accepted_solutions = accepted_solutions.where("topics.category_id = ?", category_id)
end
end
accepted_solutions
.where("discourse_solved_solved_topics.created_at >= ?", report.start_date)
.where("discourse_solved_solved_topics.created_at <= ?", report.end_date)
.group("DATE(discourse_solved_solved_topics.created_at)")
.order("DATE(discourse_solved_solved_topics.created_at)")
.count
.each { |date, count| report.data << { x: date, y: count } }
report.total = accepted_solutions.count
report.prev30Days =
accepted_solutions
.where("discourse_solved_solved_topics.created_at >= ?", report.start_date - 30.days)
.where("discourse_solved_solved_topics.created_at <= ?", report.start_date)
2017-08-02 15:09:07 +09:00
.count
end
register_modifier(:search_rank_sort_priorities) do |priorities, _search|
if SiteSetting.prioritize_solved_topics_in_search
condition = <<~SQL
EXISTS (
SELECT 1
FROM discourse_solved_solved_topics
WHERE discourse_solved_solved_topics.topic_id = topics.id
)
SQL
priorities.push([condition, 1.1])
else
priorities
end
end
register_modifier(:user_action_stream_builder) do |builder|
builder.where("t.deleted_at IS NULL").where("t.archetype <> ?", Archetype.private_message)
end
add_to_serializer(:user_card, :accepted_answers) do
DiscourseSolved::SolvedTopic
.joins(answer_post: :user, topic: {})
.where(posts: { user_id: object.id, deleted_at: nil })
.where(topics: { archetype: Archetype.default, deleted_at: nil })
.count
end
add_to_serializer(:user_summary, :solved_count) { object.solved_count }
2024-03-05 12:14:32 +01:00
add_to_serializer(:post, :can_accept_answer) do
scope.can_accept_answer?(topic, object) && object.post_number > 1 && !accepted_answer
FEATURE: Publish WebHook event when solving/unsolving (#85) * FEATURE: Publish WebHook event when solving/unsolving This feature will publish a post edit webhook event whenever a solution is accepted or unaccepted. I went ahead and used the existing post-edit webhook because all the post custom fields for the solved plugin are already included in the post-edit serializer. * Create Solved Event Webhook This commit adds a solved event webhook that will only trigger when an answer has been marked as accepted or unaccepted. It uses 100 as the webhook ID. This way any new webhooks in core can keep using lower numbers like 11, 12, 13, but plugins can use 101, 102, etc. * Removed functionality that was added to core This [PR][1] to discourse core adds what what removed in this commit. It is better to have this logic in core so that it is discoverable and future webhooks won't end up accidentally using the same ID. [1]: https://github.com/discourse/discourse/pull/9110 * UX: Add "solved" status filter in advanced search page. And rename `in:solved` to `status:solved`. * FEATURE: Publish WebHook event when solving/unsolving This feature will publish a post edit webhook event whenever a solution is accepted or unaccepted. I went ahead and used the existing post-edit webhook because all the post custom fields for the solved plugin are already included in the post-edit serializer. * Create Solved Event Webhook This commit adds a solved event webhook that will only trigger when an answer has been marked as accepted or unaccepted. It uses 100 as the webhook ID. This way any new webhooks in core can keep using lower numbers like 11, 12, 13, but plugins can use 101, 102, etc. * Removed functionality that was added to core This [PR][1] to discourse core adds what what removed in this commit. It is better to have this logic in core so that it is discoverable and future webhooks won't end up accidentally using the same ID. [1]: https://github.com/discourse/discourse/pull/9110 Co-authored-by: Vinoth Kannan <vinothkannan@vinkas.com>
2020-03-06 11:28:29 -07:00
end
2024-03-05 12:14:32 +01:00
add_to_serializer(:post, :can_unaccept_answer) do
scope.can_accept_answer?(topic, object) && accepted_answer
2015-05-19 15:45:19 +10:00
end
add_to_serializer(:post, :accepted_answer) { topic&.solved&.answer_post_id == object.id }
add_to_serializer(:post, :topic_accepted_answer) { topic&.solved&.present? }
on(:post_destroyed) { |post| ::DiscourseSolved.unaccept_answer!(post) }
2017-02-27 18:19:21 +00:00
on(:filter_auto_bump_topics) do |_category, filters|
filters.push(
->(r) do
sql = <<~SQL
NOT EXISTS (
SELECT 1
FROM discourse_solved_solved_topics
WHERE discourse_solved_solved_topics.topic_id = topics.id
)
SQL
r.where(sql)
end,
)
end
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
query = <<~SQL
WITH x AS (
SELECT p.user_id, COUNT(DISTINCT st.id) AS solutions
FROM discourse_solved_solved_topics AS st
JOIN posts AS p
ON p.id = st.answer_post_id
AND COALESCE(p.created_at, :since) > :since
AND p.deleted_at IS NULL
JOIN topics AS t
ON t.id = st.topic_id
AND t.archetype <> 'private_message'
AND t.deleted_at IS NULL
JOIN users AS u
ON u.id = p.user_id
WHERE u.id > 0
AND u.active
AND u.silenced_till IS NULL
AND u.suspended_till IS NULL
GROUP BY p.user_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
SQL
add_directory_column("solutions", query:)
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.solved.nil?
{
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
register_topic_list_preload_user_ids do |topics, user_ids|
# [{ topic_id => answer_user_id }, ... ]
topics_with_answer_poster =
DiscourseSolved::SolvedTopic
.joins(:answer_post)
.where(topic_id: topics.map(&:id))
.pluck(:topic_id, "posts.user_id")
.to_h
topics.each { |topic| topic.accepted_answer_user_id = topics_with_answer_poster[topic.id] }
user_ids.concat(topics_with_answer_poster.values)
end
DiscourseSolved::RegisterFilters.register(self)
DiscourseDev::DiscourseSolved.populate(self)
DiscourseAutomation::EntryPoint.inject(self) if defined?(DiscourseAutomation)
DiscourseAssign::EntryPoint.inject(self) if defined?(DiscourseAssign)
2015-05-19 15:45:19 +10:00
end