diff --git a/Gemfile.lock b/Gemfile.lock index c5be1a9..4b6dbc6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -35,7 +35,7 @@ GEM prettier_print (1.2.1) prism (1.4.0) racc (1.8.1) - rack (3.1.15) + rack (3.1.16) rainbow (3.1.1) regexp_parser (2.10.0) rubocop (1.75.8) diff --git a/about.json b/about.json index 0ab5dbc..17f1798 100644 --- a/about.json +++ b/about.json @@ -1,7 +1,7 @@ { "tests": { "requiredPlugins": [ - "https://github.com/discourse/discourse-assign" + "discourse-assign" ] } } diff --git a/app/controllers/discourse_solved/solved_topics_controller.rb b/app/controllers/discourse_solved/solved_topics_controller.rb new file mode 100644 index 0000000..8b6dcd4 --- /dev/null +++ b/app/controllers/discourse_solved/solved_topics_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class DiscourseSolved::SolvedTopicsController < ::ApplicationController + requires_plugin DiscourseSolved::PLUGIN_NAME + + def by_user + params.require(:username) + user = + fetch_user_from_params( + include_inactive: + current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts), + ) + raise Discourse::NotFound unless guardian.public_can_see_profiles? + raise Discourse::NotFound unless guardian.can_see_profile?(user) + + offset = [0, params[:offset].to_i].max + limit = params.fetch(:limit, 30).to_i + + posts = + Post + .joins( + "INNER JOIN discourse_solved_solved_topics ON discourse_solved_solved_topics.answer_post_id = posts.id", + ) + .joins(:topic) + .joins("LEFT JOIN categories ON categories.id = topics.category_id") + .where(user_id: user.id, deleted_at: nil) + .where(topics: { archetype: Archetype.default, deleted_at: nil }) + .where( + "topics.category_id IS NULL OR NOT categories.read_restricted OR topics.category_id IN (:secure_category_ids)", + secure_category_ids: guardian.secure_category_ids, + ) + .includes(:user, topic: %i[category tags]) + .order("discourse_solved_solved_topics.created_at DESC") + .offset(offset) + .limit(limit) + + render_serialized(posts, DiscourseSolved::SolvedPostSerializer, root: "user_solved_posts") + end +end diff --git a/app/serializers/discourse_solved/solved_post_serializer.rb b/app/serializers/discourse_solved/solved_post_serializer.rb new file mode 100644 index 0000000..b19a7c9 --- /dev/null +++ b/app/serializers/discourse_solved/solved_post_serializer.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +class DiscourseSolved::SolvedPostSerializer < ApplicationSerializer + attributes :created_at, + :archived, + :avatar_template, + :category_id, + :closed, + :cooked, + :excerpt, + :name, + :post_id, + :post_number, + :post_type, + :raw, + :slug, + :topic_id, + :topic_title, + :truncated, + :url, + :user_id, + :username + + def archived + object.topic.archived + end + + def avatar_template + object.user&.avatar_template + end + + def category_id + object.topic.category_id + end + + def closed + object.topic.closed + end + + def excerpt + @excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true) + end + + def name + object.user&.name + end + + def include_name? + SiteSetting.enable_names? + end + + def post_id + object.id + end + + def slug + Slug.for(object.topic.title) + end + + def include_slug? + object.topic.title.present? + end + + def topic_title + object.topic.title + end + + def truncated + true + end + + def include_truncated? + cooked.length > 300 + end + + def url + "#{Discourse.base_url}#{object.url}" + end + + def user_id + object.user_id + end + + def username + object.user&.username + end +end diff --git a/assets/javascripts/discourse/routes/user-activity-solved.js b/assets/javascripts/discourse/routes/user-activity-solved.js index ef41d18..62783a8 100644 --- a/assets/javascripts/discourse/routes/user-activity-solved.js +++ b/assets/javascripts/discourse/routes/user-activity-solved.js @@ -1,15 +1,110 @@ -import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import { tracked } from "@glimmer/tracking"; +import EmberObject from "@ember/object"; +import { service } from "@ember/service"; +import { Promise } from "rsvp"; +import { ajax } from "discourse/lib/ajax"; +import DiscourseRoute from "discourse/routes/discourse"; import { i18n } from "discourse-i18n"; -export default class UserActivitySolved extends UserActivityStreamRoute { - userActionType = 15; - noContentHelpKey = "solved.no_solutions"; +class SolvedPostsStream { + @tracked content = []; + @tracked loading = false; + @tracked loaded = false; + @tracked itemsLoaded = 0; + @tracked canLoadMore = true; + + constructor({ username, siteCategories }) { + this.username = username; + this.siteCategories = siteCategories; + } + + get noContent() { + return this.loaded && this.content.length === 0; + } + + findItems() { + if (this.loading || !this.canLoadMore) { + return Promise.resolve(); + } + + this.loading = true; + + const limit = 20; + return ajax( + `/solution/by_user.json?username=${this.username}&offset=${this.itemsLoaded}&limit=${limit}` + ) + .then((result) => { + const userSolvedPosts = result.user_solved_posts || []; + + if (userSolvedPosts.length === 0) { + this.canLoadMore = false; + return; + } + + const posts = userSolvedPosts.map((p) => { + const post = EmberObject.create(p); + post.set("titleHtml", post.topic_title); + post.set("postUrl", post.url); + + if (post.category_id && this.siteCategories) { + post.set( + "category", + this.siteCategories.find((c) => c.id === post.category_id) + ); + } + return post; + }); + + this.content = [...this.content, ...posts]; + this.itemsLoaded = this.itemsLoaded + userSolvedPosts.length; + + if (userSolvedPosts.length < limit) { + this.canLoadMore = false; + } + }) + .finally(() => { + this.loaded = true; + this.loading = false; + }); + } +} + +export default class UserActivitySolved extends DiscourseRoute { + @service site; + @service currentUser; + + model() { + const user = this.modelFor("user"); + + const stream = new SolvedPostsStream({ + username: user.username, + siteCategories: this.site.categories, + }); + + return stream.findItems().then(() => { + return { + stream, + emptyState: this.emptyState(), + }; + }); + } + + setupController(controller, model) { + controller.setProperties({ + model, + emptyState: this.emptyState(), + }); + } + + renderTemplate() { + this.render("user-activity-solved"); + } emptyState() { const user = this.modelFor("user"); let title, body; - if (this.isCurrentUser(user)) { + if (this.currentUser && user.id === this.currentUser.id) { title = i18n("solved.no_solved_topics_title"); body = i18n("solved.no_solved_topics_body"); } else { diff --git a/assets/javascripts/discourse/templates/user-activity-solved.gjs b/assets/javascripts/discourse/templates/user-activity-solved.gjs new file mode 100644 index 0000000..a94eb84 --- /dev/null +++ b/assets/javascripts/discourse/templates/user-activity-solved.gjs @@ -0,0 +1,16 @@ +import RouteTemplate from "ember-route-template"; +import EmptyState from "discourse/components/empty-state"; +import UserStream from "discourse/components/user-stream"; + +export default RouteTemplate( + +); diff --git a/config/locales/client.el.yml b/config/locales/client.el.yml index 5ed917f..e2329f7 100644 --- a/config/locales/client.el.yml +++ b/config/locales/client.el.yml @@ -10,6 +10,7 @@ el: alt: solved: accepted_notification: "αποδεκτό" + solutions: "Λύσεις" solved: title: "Λύθηκε" allow_accepted_answers: "Επιτρέψτε στον κάτοχο και το προσωπικό να επισημάνει μια απάντηση ως λύση" @@ -29,6 +30,13 @@ el: all: "όλα" solved: "λύθηκε" unsolved: "άλυτο" + no_solved_topics_title: "Δεν έχετε λύσει κανένα θέμα ακόμα" + no_solved_topics_title_others: "Ο χρήστης %{username} δεν έχει λύσει κανένα θέμα ακόμα" + no_answer: + title: Έχει απαντηθεί η ερώτηση σας; + description: "Επισημάνετε την απάντηση και βοηθήστε άλλους χρησιμοποιώντας το κουμπί λύσης κάτω από τη σωστή απάντηση." + notification: + title: "η ανάρτηση σας επισημάνθηκε ως λύση" topic_statuses: solved: help: "Αυτό το θέμα έχει μια λύση" @@ -36,13 +44,21 @@ el: advanced: statuses: solved: "έχουν επιλυθεί" + unsolved: "δεν έχουν επιλυθεί" admin: web_hooks: solved_event: group_name: "Επιλύθηκε γεγονός" + api: + scopes: + descriptions: + solved: + answer: Αποδοχή/Απόρριψη μίας λύσης. discourse_automation: triggerables: first_accepted_solution: + max_trust_level: + any: Οποιοδήποτε fields: maximum_trust_level: label: Επίπεδο Εμπιστοσύνης diff --git a/config/locales/server.el.yml b/config/locales/server.el.yml index 049ae28..ce69947 100644 --- a/config/locales/server.el.yml +++ b/config/locales/server.el.yml @@ -5,6 +5,7 @@ # https://translate.discourse.org/ el: + accepted_answer: "Αποδεκτή Απάντηση" site_settings: solved_enabled: "Ενεργοποιήστε το πρόσθετο solved, επιτρέψτε στους χρήστες να επιλέγουν λύσεις για θέματα" accept_all_solutions_trust_level: "Απαιτείται ελάχιστο επίπεδο εμπιστοσύνης για αποδοχή λύσεων σε οποιοδήποτε θέμα (ακόμα και όταν δεν είναι OP)" @@ -23,3 +24,7 @@ el: badges: solved_1: name: "Λύθηκε!" + discourse_automation: + triggerables: + first_accepted_solution: + title: Πρώτη αποδεκτή λύση diff --git a/config/routes.rb b/config/routes.rb index 2bfd416..2569aa2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,8 @@ DiscourseSolved::Engine.routes.draw do post "/accept" => "answer#accept" post "/unaccept" => "answer#unaccept" + + get "/by_user" => "solved_topics#by_user" end Discourse::Application.routes.draw { mount ::DiscourseSolved::Engine, at: "solution" } diff --git a/lib/discourse_solved/first_accepted_post_solution_validator.rb b/lib/discourse_solved/first_accepted_post_solution_validator.rb index 9c1deae..ae2b4b4 100644 --- a/lib/discourse_solved/first_accepted_post_solution_validator.rb +++ b/lib/discourse_solved/first_accepted_post_solution_validator.rb @@ -5,15 +5,15 @@ module DiscourseSolved 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 + if trust_level != "any" && TrustLevel.compare(post&.user&.trust_level, trust_level.to_i) + return false end - false + !DiscourseSolved::SolvedTopic + .joins(:answer_post) + .where("posts.user_id = ? AND posts.id != ?", post.user_id, post.id) + .exists? end end end diff --git a/spec/lib/first_accepted_post_solution_validator_spec.rb b/spec/lib/first_accepted_post_solution_validator_spec.rb index 75139a9..87bd027 100644 --- a/spec/lib/first_accepted_post_solution_validator_spec.rb +++ b/spec/lib/first_accepted_post_solution_validator_spec.rb @@ -1,56 +1,43 @@ # frozen_string_literal: true -require "rails_helper" - describe DiscourseSolved::FirstAcceptedPostSolutionValidator do fab!(:user_tl1) { Fabricate(:user, trust_level: TrustLevel[1], refresh_auto_groups: true) } - context "when user is under max trust level" do - context "with no post accepted yet" do - it "validates the post" do - post_1 = create_post(user: user_tl1) - expect(described_class.check(post_1, trust_level: TrustLevel[2])).to eq(true) - end - end - - context "with already had accepted posts" do - before do - accepted_post = create_post(user: user_tl1) - DiscourseSolved.accept_answer!(accepted_post, Discourse.system_user) - end - - it "doesn’t validate the post" do - post_1 = create_post(user: user_tl1) - expect(described_class.check(post_1, trust_level: TrustLevel[2])).to eq(false) - end - end - end - - context "when a user is above or equal max trust level" do - context "with no post accepted yet" do - it "doesn’t validate the post" do - post_1 = create_post(user: user_tl1) - expect(described_class.check(post_1, trust_level: TrustLevel[1])).to eq(false) - end - end - - context "when a post is already accepted" do - before do - accepted_post = create_post(user: user_tl1) - DiscourseSolved.accept_answer!(accepted_post, Discourse.system_user) - end - - it "doesn’t validate the post" do - post_1 = create_post(user: user_tl1) - expect(described_class.check(post_1, trust_level: TrustLevel[1])).to eq(false) - end - end - end - - context "when using any trust level" do + context "when trust level is 'any'" do it "validates the post" do - post_1 = create_post(user: user_tl1) - expect(described_class.check(post_1, trust_level: "any")).to eq(true) + post = Fabricate(:post, user: user_tl1) + DiscourseSolved.accept_answer!(post, Discourse.system_user) + + expect(described_class.check(post, trust_level: "any")).to eq(true) + end + + it "invalidates if post user already has an accepted post" do + previously_accepted_post = Fabricate(:post, user: user_tl1) + DiscourseSolved.accept_answer!(previously_accepted_post, Discourse.system_user) + + newly_accepted_post = Fabricate(:post, user: user_tl1) + DiscourseSolved.accept_answer!(newly_accepted_post, Discourse.system_user) + + expect(described_class.check(newly_accepted_post, trust_level: "any")).to eq(false) + end + end + + context "with specified trust level that is not 'any'" do + # the automation indicates "users under this Trust Level will trigger this automation" + + it "invalidates if the user is higher than or equal to the specified trust level" do + post = Fabricate(:post, user: user_tl1) + DiscourseSolved.accept_answer!(post, Discourse.system_user) + + expect(described_class.check(post, trust_level: TrustLevel[0])).to eq(false) + expect(described_class.check(post, trust_level: TrustLevel[1])).to eq(false) + end + + it "validates the post when user is under specified trust level" do + post = Fabricate(:post, user: user_tl1) + DiscourseSolved.accept_answer!(post, Discourse.system_user) + + expect(described_class.check(post, trust_level: TrustLevel[2])).to eq(true) end end diff --git a/spec/requests/solved_topics_controller_spec.rb b/spec/requests/solved_topics_controller_spec.rb new file mode 100644 index 0000000..2cd1ce2 --- /dev/null +++ b/spec/requests/solved_topics_controller_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +describe DiscourseSolved::SolvedTopicsController do + fab!(:user) + fab!(:another_user) { Fabricate(:user) } + fab!(:admin) + fab!(:topic) + fab!(:post) { Fabricate(:post, topic:) } + fab!(:answer_post) { Fabricate(:post, topic:, user:) } + fab!(:solved_topic) { Fabricate(:solved_topic, topic:, answer_post:) } + + describe "#by_user" do + context "when accessing with username" do + it "returns solved posts for the specified user" do + sign_in(admin) + + get "/solution/by_user.json", params: { username: user.username } + + expect(response.status).to eq(200) + result = response.parsed_body + expect(result["user_solved_posts"]).to be_present + expect(result["user_solved_posts"].length).to eq(1) + expect(result["user_solved_posts"][0]["post_id"]).to eq(answer_post.id) + end + + it "returns 404 for a non-existent user" do + sign_in(admin) + + get "/solution/by_user.json", params: { username: "non-existent-user" } + + expect(response.status).to eq(404) + end + + it "correctly handles the offset parameter" do + sign_in(admin) + + get "/solution/by_user.json", params: { username: user.username, offset: 1 } + + expect(response.status).to eq(200) + result = response.parsed_body + expect(result["user_solved_posts"]).to be_empty + end + + it "correctly handles the limit parameter" do + Fabricate(:solved_topic, answer_post: Fabricate(:post, user:)) + + sign_in(admin) + + get "/solution/by_user.json", params: { username: user.username, limit: 1 } + + expect(response.status).to eq(200) + result = response.parsed_body + expect(result["user_solved_posts"].length).to eq(1) + end + end + + context "when accessing without username" do + it "returns 400 for the current user" do + sign_in(user) + + get "/solution/by_user.json" + + expect(response.status).to eq(400) + end + + it "returns 400 if not logged in" do + get "/solution/by_user.json" + + expect(response.status).to eq(400) + end + end + + context "with visibility restrictions" do + context "with private category solved topic" do + fab!(:group) { Fabricate(:group).tap { |g| g.add(user) } } + fab!(:private_category) { Fabricate(:private_category, group:) } + fab!(:private_topic) { Fabricate(:topic, category: private_category) } + fab!(:private_post) { Fabricate(:post, topic: private_topic) } + fab!(:private_answer_post) { Fabricate(:post, topic: private_topic, user: user) } + fab!(:private_solved_topic) do + Fabricate(:solved_topic, topic: private_topic, answer_post: private_answer_post) + end + + it "respects category permissions" do + sign_in(another_user) + + get "/solution/by_user.json", params: { username: user.username } + + expect(response.status).to eq(200) + result = response.parsed_body + # admin sees both solutions + expect(result["user_solved_posts"].length).to eq(1) + + sign_in(user) + + get "/solution/by_user.json", params: { username: user.username } + + expect(response.status).to eq(200) + result = response.parsed_body + expect(result["user_solved_posts"].length).to eq(2) + end + end + + it "does not return PMs" do + topic.update(archetype: Archetype.private_message, category: nil) + + sign_in(user) + + get "/solution/by_user.json", params: { username: user.username } + + expect(response.status).to eq(200) + result = response.parsed_body + expect(result["user_solved_posts"]).to be_empty + end + end + end +end diff --git a/spec/system/solved_spec.rb b/spec/system/solved_spec.rb index ff22502..40a5a67 100644 --- a/spec/system/solved_spec.rb +++ b/spec/system/solved_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -describe "About page", type: :system do +describe "Solved", type: :system do fab!(:admin) fab!(:solver) { Fabricate(:user) } fab!(:accepter) { Fabricate(:user) } fab!(:topic) { Fabricate(:post, user: admin).topic } - fab!(:post1) { Fabricate(:post, topic:, user: solver, cooked: "The answer is 42") } + fab!(:solver_post) { Fabricate(:post, topic:, user: solver, cooked: "The answer is 42") } let(:topic_page) { PageObjects::Pages::Topic.new } before do @@ -62,4 +62,13 @@ describe "About page", type: :system do end end end + + it "shows the solved post in user activity at /my/activity/solved" do + Fabricate(:solved_topic, topic:, answer_post: solver_post, accepter:) + + sign_in(solver) + visit "/my/activity/solved" + + expect(page.find(".post-list")).to have_content(solver_post.cooked) + end end diff --git a/test/javascripts/acceptance/user-activity-solved-test.js b/test/javascripts/acceptance/user-activity-solved-test.js index ce55280..7a28167 100644 --- a/test/javascripts/acceptance/user-activity-solved-test.js +++ b/test/javascripts/acceptance/user-activity-solved-test.js @@ -9,8 +9,8 @@ acceptance( needs.user(); needs.pretender((server, helper) => { - server.get("/user_actions.json", () => - helper.response({ user_actions: [] }) + server.get("/solution/by_user.json", () => + helper.response({ user_solved_posts: [] }) ); }); @@ -18,22 +18,22 @@ acceptance( await visit(`/u/eviltrout/activity/solved`); assert - .dom("div.empty-state span.empty-state-title") + .dom(".empty-state .empty-state__title") .hasText(i18n("solved.no_solved_topics_title")); assert - .dom("div.empty-state div.empty-state-body") + .dom(".empty-state .empty-state__body") .hasText(i18n("solved.no_solved_topics_body")); }); test("When looking at another user's activity", async function (assert) { await visit(`/u/charlie/activity/solved`); - assert.dom("div.empty-state span.empty-state-title").hasText( + assert.dom(".empty-state .empty-state__title").hasText( i18n("solved.no_solved_topics_title_others", { username: "charlie", }) ); - assert.dom("div.empty-state div.empty-state-body").hasNoText(); + assert.dom(".empty-state .empty-state__body").doesNotExist(); }); } );