diff --git a/Gemfile.lock b/Gemfile.lock index 53ede95..4c42077 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,30 @@ GEM remote: https://rubygems.org/ specs: + activesupport (7.1.3.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) ast (2.4.2) + base64 (0.2.0) + bigdecimal (3.1.6) + concurrent-ruby (1.2.3) + connection_pool (2.4.1) + drb (2.2.1) + i18n (1.14.1) + concurrent-ruby (~> 1.0) json (2.7.1) language_server-protocol (3.17.0.3) + minitest (5.22.2) + mutex_m (0.2.0) parallel (1.24.0) - parser (3.3.0.4) + parser (3.3.0.5) ast (~> 2.4.1) racc prettier_print (1.2.1) @@ -13,7 +32,7 @@ GEM rainbow (3.1.1) regexp_parser (2.9.0) rexml (3.2.6) - rubocop (1.60.0) + rubocop (1.61.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -24,22 +43,27 @@ GEM rubocop-ast (>= 1.30.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.30.0) - parser (>= 3.2.1.0) + rubocop-ast (1.31.1) + parser (>= 3.3.0.4) rubocop-capybara (2.20.0) rubocop (~> 1.41) - rubocop-discourse (3.6.0) + rubocop-discourse (3.7.1) + activesupport (>= 6.1) rubocop (>= 1.59.0) + rubocop-capybara (>= 2.0.0) + rubocop-factory_bot (>= 2.0.0) rubocop-rspec (>= 2.25.0) rubocop-factory_bot (2.25.1) rubocop (~> 1.41) - rubocop-rspec (2.26.1) + rubocop-rspec (2.27.1) rubocop (~> 1.40) rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) ruby-progressbar (1.13.0) syntax_tree (6.2.0) prettier_print (>= 1.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) PLATFORMS diff --git a/app/controllers/answer_controller.rb b/app/controllers/answer_controller.rb new file mode 100644 index 0000000..ced07b0 --- /dev/null +++ b/app/controllers/answer_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class DiscourseSolved::AnswerController < ::ApplicationController + requires_plugin DiscourseSolved::PLUGIN_NAME + + 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 diff --git a/app/lib/before_head_close.rb b/app/lib/before_head_close.rb new file mode 100644 index 0000000..b07b9bf --- /dev/null +++ b/app/lib/before_head_close.rb @@ -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 + + [ + '", + ].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 diff --git a/app/lib/category_extension.rb b/app/lib/category_extension.rb new file mode 100644 index 0000000..80dd9ae --- /dev/null +++ b/app/lib/category_extension.rb @@ -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 diff --git a/app/lib/post_serializer_extension.rb b/app/lib/post_serializer_extension.rb new file mode 100644 index 0000000..f571440 --- /dev/null +++ b/app/lib/post_serializer_extension.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module DiscourseSolved::PostSerializerExtension + extend ActiveSupport::Concern + + private + + def topic + topic_view&.topic || object.topic + end +end diff --git a/app/lib/topic_posters_summary_extension.rb b/app/lib/topic_posters_summary_extension.rb new file mode 100644 index 0000000..edbed74 --- /dev/null +++ b/app/lib/topic_posters_summary_extension.rb @@ -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 diff --git a/app/lib/topic_view_serializer_extension.rb b/app/lib/topic_view_serializer_extension.rb new file mode 100644 index 0000000..05008cc --- /dev/null +++ b/app/lib/topic_view_serializer_extension.rb @@ -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 diff --git a/app/lib/user_summary_extension.rb b/app/lib/user_summary_extension.rb new file mode 100644 index 0000000..5194c03 --- /dev/null +++ b/app/lib/user_summary_extension.rb @@ -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 diff --git a/app/lib/web_hook_extension.rb b/app/lib/web_hook_extension.rb new file mode 100644 index 0000000..4bb84e0 --- /dev/null +++ b/app/lib/web_hook_extension.rb @@ -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 diff --git a/plugin.rb b/plugin.rb index 87fa045..31dad15 100644 --- a/plugin.rb +++ b/plugin.rb @@ -15,74 +15,39 @@ 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 = < "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 - - [ - '", - ].join("") - end - register_html_builder("server:before-head-close-crawler") do |controller| - before_head_close_meta(controller) + DiscourseSolved::BeforeHeadClose.new(controller).html end register_html_builder("server:before-head-close") do |controller| - before_head_close_meta(controller) + DiscourseSolved::BeforeHeadClose.new(controller).html end if Report.respond_to?(:add_report) @@ -414,129 +328,22 @@ def before_head_close_meta(controller) 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 + if defined?(::UserAction::SOLVED) + add_to_serializer(:user_summary, :solved_count) { object.solved_count } 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 + add_to_serializer(:post, :can_accept_answer) do + scope.can_accept_answer?(topic, object) && object.post_number > 1 && !accepted_answer 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 + add_to_serializer(:post, :can_unaccept_answer) do + scope.can_accept_answer?(topic, object) && accepted_answer end - - class ::Category - after_save :reset_accepted_cache - - protected - - def reset_accepted_cache - ::DiscourseSolved::AcceptedAnswerCache.reset_accepted_answer_cache - end + add_to_serializer(:post, :accepted_answer) do + post_custom_fields[::DiscourseSolved::IS_ACCEPTED_ANSWER_CUSTOM_FIELD] == "true" 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 + add_to_serializer(:post, :topic_accepted_answer) do + topic&.custom_fields&.[](::DiscourseSolved::ACCEPTED_ANSWER_POST_ID_CUSTOM_FIELD).present? end - require_dependency "search" - #TODO Remove when plugin is 1.0 if Search.respond_to? :advanced_filter Search.advanced_filter(/status:solved/) do |posts| @@ -581,8 +388,6 @@ def topic 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 = @@ -609,31 +414,6 @@ def topic 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 @@ -756,10 +536,6 @@ class ::ListableTopicSerializer 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 @@ -770,39 +546,6 @@ class ::Topic 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) @@ -830,29 +573,6 @@ def user_ids 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 @@ -861,7 +581,33 @@ def user_ids field :maximum_trust_level, component: :choices, extra: { - content: TRUST_LEVELS, + 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 diff --git a/spec/components/composer_messages_finder_spec.rb b/spec/components/composer_messages_finder_spec.rb index 8cd15ec..944fa8b 100644 --- a/spec/components/composer_messages_finder_spec.rb +++ b/spec/components/composer_messages_finder_spec.rb @@ -6,8 +6,8 @@ describe ComposerMessagesFinder do describe ".check_topic_is_solved" do - fab!(:user) { Fabricate(:user) } - fab!(:topic) { Fabricate(:topic) } + fab!(:user) + fab!(:topic) fab!(:post) { Fabricate(:post, topic: topic, user: Fabricate(:user)) } before { SiteSetting.disable_solved_education_message = false } diff --git a/spec/components/post_revisor_spec.rb b/spec/components/post_revisor_spec.rb index 2d4be63..d35cb82 100644 --- a/spec/components/post_revisor_spec.rb +++ b/spec/components/post_revisor_spec.rb @@ -41,7 +41,7 @@ fab!(:tag1) { Fabricate(:tag) } fab!(:tag2) { Fabricate(:tag) } - fab!(:topic) { Fabricate(:topic) } + fab!(:topic) let(:post) { Fabricate(:post, topic: topic) } it "sets the refresh option after adding an allowed tag" do diff --git a/spec/integration/solved_spec.rb b/spec/integration/solved_spec.rb index 3fd6c42..93dc089 100644 --- a/spec/integration/solved_spec.rb +++ b/spec/integration/solved_spec.rb @@ -340,7 +340,7 @@ end context "with group moderators" do - fab!(:group_user) { Fabricate(:group_user) } + fab!(:group_user) let(:user_gm) { group_user.user } let(:group) { group_user.group }