diff --git a/app/assets/javascripts/discourse/adapters/reviewable-explanation.js.es6 b/app/assets/javascripts/discourse/adapters/reviewable-explanation.js.es6
new file mode 100644
index 00000000000..2ae3837e947
--- /dev/null
+++ b/app/assets/javascripts/discourse/adapters/reviewable-explanation.js.es6
@@ -0,0 +1,9 @@
+import RestAdapter from "discourse/adapters/rest";
+
+export default RestAdapter.extend({
+ jsonMode: true,
+
+ pathFor(store, type, id) {
+ return `/review/${id}/explain.json`;
+ }
+});
diff --git a/app/assets/javascripts/discourse/components/reviewable-item.js.es6 b/app/assets/javascripts/discourse/components/reviewable-item.js.es6
index d94128f3489..4507704d95b 100644
--- a/app/assets/javascripts/discourse/components/reviewable-item.js.es6
+++ b/app/assets/javascripts/discourse/components/reviewable-item.js.es6
@@ -3,6 +3,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import computed from "ember-addons/ember-computed-decorators";
import Category from "discourse/models/category";
import optionalService from "discourse/lib/optional-service";
+import showModal from "discourse/lib/show-modal";
let _components = {};
@@ -140,6 +141,13 @@ export default Ember.Component.extend({
},
actions: {
+ explainReviewable(reviewable) {
+ showModal("explain-reviewable", {
+ title: "review.explain.title",
+ model: reviewable
+ });
+ },
+
edit() {
this.set("editing", true);
this._updates = { payload: {} };
diff --git a/app/assets/javascripts/discourse/controllers/explain-reviewable.js.es6 b/app/assets/javascripts/discourse/controllers/explain-reviewable.js.es6
new file mode 100644
index 00000000000..49e57228a62
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/explain-reviewable.js.es6
@@ -0,0 +1,15 @@
+import ModalFunctionality from "discourse/mixins/modal-functionality";
+
+export default Ember.Controller.extend(ModalFunctionality, {
+ loading: null,
+ reviewableExplanation: null,
+
+ onShow() {
+ this.setProperties({ loading: true, reviewableExplanation: null });
+
+ this.store
+ .find("reviewable-explanation", this.model.id)
+ .then(result => this.set("reviewableExplanation", result))
+ .finally(() => this.set("loading", false));
+ }
+});
diff --git a/app/assets/javascripts/discourse/helpers/float.js.es6 b/app/assets/javascripts/discourse/helpers/float.js.es6
new file mode 100644
index 00000000000..4d0fa564a24
--- /dev/null
+++ b/app/assets/javascripts/discourse/helpers/float.js.es6
@@ -0,0 +1,5 @@
+import { registerUnbound } from "discourse-common/lib/helpers";
+
+registerUnbound("float", function(n) {
+ return parseFloat(n).toFixed(1);
+});
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-item.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-item.hbs
index 7df7ee102eb..abd106a474a 100644
--- a/app/assets/javascripts/discourse/templates/components/reviewable-item.hbs
+++ b/app/assets/javascripts/discourse/templates/components/reviewable-item.hbs
@@ -10,6 +10,9 @@
{{reviewable-status reviewable.status}}
+
+ {{d-icon "question-circle"}}
+
diff --git a/app/assets/javascripts/discourse/templates/components/score-value.hbs b/app/assets/javascripts/discourse/templates/components/score-value.hbs
new file mode 100644
index 00000000000..e78eeb32b23
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/components/score-value.hbs
@@ -0,0 +1,11 @@
+{{#if value}}
+
+ {{float value}}
+ {{#if label}}
+
+ {{i18n (concat "review.explain." label ".name")}}
+
+ {{/if}}
+
+
+
+{{/if}}
diff --git a/app/assets/javascripts/discourse/templates/modal/explain-reviewable.hbs b/app/assets/javascripts/discourse/templates/modal/explain-reviewable.hbs
new file mode 100644
index 00000000000..76f955c45b0
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/modal/explain-reviewable.hbs
@@ -0,0 +1,47 @@
+{{#d-modal-body class="explain-reviewable"}}
+ {{#conditional-loading-spinner condition=loading}}
+
+
+ {{i18n "review.explain.formula"}} |
+ {{i18n "review.explain.subtotal"}} |
+
+ {{#each reviewableExplanation.scores as |s|}}
+
+
+ {{score-value value="1.0" tagName=""}}
+ {{score-value value=s.type_bonus label="type_bonus" tagName=""}}
+ {{score-value value=s.take_action_bonus label="take_action_bonus" tagName=""}}
+ {{score-value value=s.trust_level_bonus label="trust_level_bonus" tagName=""}}
+ {{score-value value=s.user_accuracy_bonus label="user_accuracy_bonus" tagName=""}}
+ |
+ {{float s.score}} |
+
+ {{/each}}
+
+ {{i18n "review.explain.total"}} |
+ {{float reviewableExplanation.total_score}} |
+
+
+
+
+
+ {{i18n "review.explain.min_score_visibility"}} |
+
+ {{float reviewableExplanation.min_score_visibility}}
+ |
+
+
+ {{i18n "review.explain.score_to_hide"}} |
+
+ {{float reviewableExplanation.hide_post_score}}
+ |
+
+
+
+ {{/conditional-loading-spinner}}
+
+{{/d-modal-body}}
+
+
diff --git a/app/assets/stylesheets/common/base/explain-reviewable.scss b/app/assets/stylesheets/common/base/explain-reviewable.scss
new file mode 100644
index 00000000000..ec7026e8139
--- /dev/null
+++ b/app/assets/stylesheets/common/base/explain-reviewable.scss
@@ -0,0 +1,37 @@
+.explain-reviewable {
+ min-width: 500px;
+
+ .thresholds {
+ margin-top: 1em;
+ }
+ table {
+ width: 100%;
+ }
+ table td {
+ padding: 0.5em;
+ }
+ td.sum {
+ text-align: right;
+ }
+ td.sum.total {
+ font-weight: bold;
+ }
+ tr.total {
+ td {
+ background-color: $primary-low;
+ font-weight: bold;
+ }
+ }
+
+ .op {
+ font-weight: bold;
+ }
+
+ .score-value-type {
+ color: $primary-medium;
+ }
+
+ .op:last-of-type {
+ display: none;
+ }
+}
diff --git a/app/assets/stylesheets/common/base/reviewables.scss b/app/assets/stylesheets/common/base/reviewables.scss
index 31071ac39d3..0228f0c1c70 100644
--- a/app/assets/stylesheets/common/base/reviewables.scss
+++ b/app/assets/stylesheets/common/base/reviewables.scss
@@ -20,6 +20,9 @@
}
}
}
+ .explain {
+ margin-left: 0.5em;
+ }
.nav-pills {
margin-bottom: 1em;
diff --git a/app/controllers/reviewables_controller.rb b/app/controllers/reviewables_controller.rb
index d2bed1f9e48..dffe6cd1724 100644
--- a/app/controllers/reviewables_controller.rb
+++ b/app/controllers/reviewables_controller.rb
@@ -1,4 +1,5 @@
# frozen_string_literal: true
+require_dependency 'reviewable_explanation_serializer'
class ReviewablesController < ApplicationController
requires_login
@@ -102,6 +103,17 @@ class ReviewablesController < ApplicationController
)
end
+ def explain
+ reviewable = find_reviewable
+
+ render_serialized(
+ { reviewable: reviewable, scores: reviewable.explain_score },
+ ReviewableExplanationSerializer,
+ rest_serializer: true,
+ root: 'reviewable_explanation'
+ )
+ end
+
def show
reviewable = find_reviewable
diff --git a/app/models/reviewable.rb b/app/models/reviewable.rb
index b0142d1eb3f..dd7be14eb37 100644
--- a/app/models/reviewable.rb
+++ b/app/models/reviewable.rb
@@ -481,6 +481,25 @@ class Reviewable < ActiveRecord::Base
.count
end
+ def explain_score
+ DB.query(<<~SQL, reviewable_id: id)
+ SELECT rs.reviewable_id,
+ rs.user_id,
+ CASE WHEN (u.admin OR u.moderator) THEN 5.0 ELSE u.trust_level END AS trust_level_bonus,
+ us.flags_agreed,
+ us.flags_disagreed,
+ us.flags_ignored,
+ rs.score,
+ rs.take_action_bonus,
+ COALESCE(pat.score_bonus, 0.0) AS type_bonus
+ FROM reviewable_scores AS rs
+ INNER JOIN users AS u ON u.id = rs.user_id
+ LEFT OUTER JOIN user_stats AS us ON us.user_id = rs.user_id
+ LEFT OUTER JOIN post_action_types AS pat ON pat.id = rs.reviewable_score_type
+ WHERE rs.reviewable_id = :reviewable_id
+ SQL
+ end
+
protected
def recalculate_score
diff --git a/app/models/reviewable_score.rb b/app/models/reviewable_score.rb
index 0063e2458a2..14ba16ed48e 100644
--- a/app/models/reviewable_score.rb
+++ b/app/models/reviewable_score.rb
@@ -59,10 +59,22 @@ class ReviewableScore < ActiveRecord::Base
user_stat = user&.user_stat
return 0.0 if user_stat.blank?
- total = (user_stat.flags_agreed + user_stat.flags_disagreed + user_stat.flags_ignored).to_f
+ calc_user_accuracy_bonus(
+ user_stat.flags_agreed,
+ user_stat.flags_disagreed,
+ user_stat.flags_ignored
+ )
+ end
+
+ def self.calc_user_accuracy_bonus(agreed, disagreed, ignored)
+ agreed ||= 0
+ disagreed ||= 0
+ ignored ||= 0
+
+ total = (agreed + disagreed + ignored).to_f
return 0.0 if total <= 5
- (user_stat.flags_agreed / total) * 5.0
+ (agreed / total) * 5.0
end
def reviewable_conversation
diff --git a/app/serializers/reviewable_explanation_serializer.rb b/app/serializers/reviewable_explanation_serializer.rb
new file mode 100644
index 00000000000..057c4dc3ce0
--- /dev/null
+++ b/app/serializers/reviewable_explanation_serializer.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+require_dependency 'reviewable_score_explanation_serializer'
+
+class ReviewableExplanationSerializer < ApplicationSerializer
+ attributes(
+ :id,
+ :total_score,
+ :scores,
+ :min_score_visibility,
+ :hide_post_score
+ )
+
+ has_many :scores, serializer: ReviewableScoreExplanationSerializer, embed: :objects
+
+ def id
+ object[:reviewable].id
+ end
+
+ def hide_post_score
+ Reviewable.score_required_to_hide_post
+ end
+
+ def spam_silence_score
+ Reviewable.spam_score_to_silence_new_user
+ end
+
+ def min_score_visibility
+ Reviewable.min_score_for_priority
+ end
+
+ def total_score
+ object[:reviewable].score
+ end
+
+ def scores
+ object[:scores]
+ end
+end
diff --git a/app/serializers/reviewable_score_explanation_serializer.rb b/app/serializers/reviewable_score_explanation_serializer.rb
new file mode 100644
index 00000000000..caff0cee266
--- /dev/null
+++ b/app/serializers/reviewable_score_explanation_serializer.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class ReviewableScoreExplanationSerializer < ApplicationSerializer
+ attributes(
+ :user_id,
+ :type_bonus,
+ :trust_level_bonus,
+ :take_action_bonus,
+ :flags_agreed,
+ :flags_disagreed,
+ :flags_ignored,
+ :user_accuracy_bonus,
+ :score
+ )
+
+ def user_accuracy_bonus
+ ReviewableScore.calc_user_accuracy_bonus(
+ object.flags_agreed,
+ object.flags_disagreed,
+ object.flags_ignored
+ )
+ end
+
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index cfcafba0474..2b0eab6e196 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -370,6 +370,23 @@ en:
review:
order_by: "Order by"
in_reply_to: "in reply to"
+ explain:
+ why: "explain why this item ended up in the queue"
+ title: "Reviewable Scoring"
+ formula: "Formula"
+ subtotal: "Subtotal"
+ total: "Total"
+ min_score_visibility: "Minimum Score for Visibility"
+ score_to_hide: "Score to Hide Post"
+ user_accuracy_bonus:
+ name: "user accuracy"
+ title: "Users whose flags have been historically agreed with are given a bonus."
+ trust_level_bonus:
+ name: "trust level"
+ title: "Reviewable items created by higher trust level users have a higher score."
+ type_bonus:
+ name: "type bonus"
+ title: "Certain reviewable types can be assigned a bonus by staff to make them a higher priority."
claim_help:
optional: "You can claim this item to prevent others from reviewing it."
required: "You must claim items before you can review them."
diff --git a/config/routes.rb b/config/routes.rb
index 7b638267d6a..328ad0e8b94 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -324,6 +324,7 @@ Discourse::Application.routes.draw do
get "review" => "reviewables#index" # For ember app
get "review/:reviewable_id" => "reviewables#show", constraints: { reviewable_id: /\d+/ }
+ get "review/:reviewable_id/explain" => "reviewables#explain", constraints: { reviewable_id: /\d+/ }
get "review/topics" => "reviewables#topics"
get "review/settings" => "reviewables#settings"
put "review/settings" => "reviewables#settings"
diff --git a/spec/requests/reviewables_controller_spec.rb b/spec/requests/reviewables_controller_spec.rb
index d83135e79ff..dba29199b01 100644
--- a/spec/requests/reviewables_controller_spec.rb
+++ b/spec/requests/reviewables_controller_spec.rb
@@ -236,6 +236,30 @@ describe ReviewablesController do
end
end
+ context "#explain" do
+ context "basics" do
+ fab!(:reviewable) { Fabricate(:reviewable) }
+
+ before do
+ sign_in(Fabricate(:moderator))
+ end
+
+ it "returns the explanation as json" do
+ get "/review/#{reviewable.id}/explain.json"
+ expect(response.code).to eq("200")
+
+ json = ::JSON.parse(response.body)
+ expect(json['reviewable_explanation']['id']).to eq(reviewable.id)
+ expect(json['reviewable_explanation']['total_score']).to eq(reviewable.score)
+ end
+
+ it "returns 404 for a missing reviewable" do
+ get "/review/123456789/explain.json"
+ expect(response.code).to eq("404")
+ end
+ end
+ end
+
context "#perform" do
fab!(:reviewable) { Fabricate(:reviewable) }
before do