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 0000000000..2ae3837e94
--- /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/groups-form-membership-fields.js.es6 b/app/assets/javascripts/discourse/components/groups-form-membership-fields.js.es6
index 6cf923d39d..7a9849463a 100644
--- a/app/assets/javascripts/discourse/components/groups-form-membership-fields.js.es6
+++ b/app/assets/javascripts/discourse/components/groups-form-membership-fields.js.es6
@@ -19,12 +19,12 @@ export default Ember.Component.extend({
@computed("model.visibility_level", "model.public_admission")
disableMembershipRequestSetting(visibility_level, publicAdmission) {
visibility_level = parseInt(visibility_level);
- return ![0, 1].includes(visibility_level) || publicAdmission;
+ return publicAdmission || visibility_level > 1;
},
@computed("model.visibility_level", "model.allow_membership_requests")
disablePublicSetting(visibility_level, allowMembershipRequests) {
visibility_level = parseInt(visibility_level);
- return ![0, 1].includes(visibility_level) || allowMembershipRequests;
+ return allowMembershipRequests || visibility_level > 1;
}
});
diff --git a/app/assets/javascripts/discourse/components/reviewable-item.js.es6 b/app/assets/javascripts/discourse/components/reviewable-item.js.es6
index d94128f348..4507704d95 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 0000000000..49e57228a6
--- /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 0000000000..4d0fa564a2
--- /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/models/group.js.es6 b/app/assets/javascripts/discourse/models/group.js.es6
index 383c46861b..2b0081e4e0 100644
--- a/app/assets/javascripts/discourse/models/group.js.es6
+++ b/app/assets/javascripts/discourse/models/group.js.es6
@@ -139,7 +139,7 @@ const Group = RestModel.extend({
@computed("visibility_level")
isPrivate(visibilityLevel) {
- return ![0, 1].includes(visibilityLevel);
+ return visibilityLevel > 1;
},
@observes("isPrivate", "canEveryoneMention")
diff --git a/app/assets/javascripts/discourse/models/login-method.js.es6 b/app/assets/javascripts/discourse/models/login-method.js.es6
index 83e9a18a9d..0b1260447a 100644
--- a/app/assets/javascripts/discourse/models/login-method.js.es6
+++ b/app/assets/javascripts/discourse/models/login-method.js.es6
@@ -53,7 +53,7 @@ const LoginMethod = Ember.Object.extend({
}
LoginMethod.buildPostForm(authUrl).then(form => {
const windowState = window.open(
- authUrl,
+ "about:blank",
"auth_popup",
`menubar=no,status=no,height=${height},width=${width},left=${left},top=${top}`
);
diff --git a/app/assets/javascripts/discourse/routes/tags-show.js.es6 b/app/assets/javascripts/discourse/routes/tags-show.js.es6
index 49a4231d17..3a10287935 100644
--- a/app/assets/javascripts/discourse/routes/tags-show.js.es6
+++ b/app/assets/javascripts/discourse/routes/tags-show.js.es6
@@ -73,8 +73,9 @@ export default Discourse.Route.extend({
const params = filterQueryParams(transition.to.queryParams, {});
const categorySlug = this.categorySlug;
const parentCategorySlug = this.parentCategorySlug;
- const filter = this.navMode;
+ const topicFilter = this.navMode;
const tagId = tag ? tag.id.toLowerCase() : "none";
+ let filter;
if (categorySlug) {
const category = Discourse.Category.findBySlug(
@@ -82,31 +83,25 @@ export default Discourse.Route.extend({
parentCategorySlug
);
if (parentCategorySlug) {
- params.filter = `tags/c/${parentCategorySlug}/${categorySlug}/${tagId}/l/${filter}`;
+ filter = `tags/c/${parentCategorySlug}/${categorySlug}/${tagId}/l/${topicFilter}`;
} else {
- params.filter = `tags/c/${categorySlug}/${tagId}/l/${filter}`;
+ filter = `tags/c/${categorySlug}/${tagId}/l/${topicFilter}`;
}
if (category) {
category.setupGroupsAndPermissions();
this.set("category", category);
}
} else if (this.additionalTags) {
- params.filter = `tags/intersection/${tagId}/${this.get(
- "additionalTags"
- ).join("/")}`;
+ filter = `tags/intersection/${tagId}/${this.additionalTags.join("/")}`;
this.set("category", null);
} else {
- params.filter = `tags/${tagId}/l/${filter}`;
+ filter = `tags/${tagId}/l/${topicFilter}`;
this.set("category", null);
}
- return findTopicList(
- this.store,
- this.topicTrackingState,
- params.filter,
- params,
- { cached: true }
- ).then(list => {
+ return findTopicList(this.store, this.topicTrackingState, filter, params, {
+ cached: true
+ }).then(list => {
if (list.topic_list.tags && list.topic_list.tags.length === 1) {
// Update name of tag (case might be different)
tag.setProperties({
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-item.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-item.hbs
index 7df7ee102e..abd106a474 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 0000000000..e78eeb32b2
--- /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 0000000000..76f955c45b
--- /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/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs
index e5625d000e..c4122b52fb 100644
--- a/app/assets/javascripts/discourse/templates/topic.hbs
+++ b/app/assets/javascripts/discourse/templates/topic.hbs
@@ -145,6 +145,7 @@
topic=model
expanded=info.topicProgressExpanded
jumpToPost=(action "jumpToPost")}}
+ {{plugin-outlet name="before-topic-progress" args=(hash model=model jumpToPost=(action "jumpToPost"))}}
{{#if info.renderAdminMenuButton}}
{{topic-admin-menu-button
topic=model
diff --git a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-menu.js.es6
index db2ac3ba5c..eb14943d2e 100644
--- a/app/assets/javascripts/discourse/widgets/post-menu.js.es6
+++ b/app/assets/javascripts/discourse/widgets/post-menu.js.es6
@@ -69,15 +69,14 @@ registerButton("read-count", attrs => {
});
registerButton("read", attrs => {
- const disabled = attrs.readCount === 0;
- if (attrs.showReadIndicator) {
+ const readBySomeone = attrs.readCount > 0;
+ if (attrs.showReadIndicator && readBySomeone) {
return {
action: "toggleWhoRead",
title: "post.controls.read_indicator",
icon: "book-reader",
before: "read-count",
- addContainer: false,
- disabled
+ addContainer: false
};
}
});
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 0000000000..ec7026e813
--- /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 31071ac39d..0228f0c1c7 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/assets/stylesheets/common/topic-timeline.scss b/app/assets/stylesheets/common/topic-timeline.scss
index d9b8628b5e..f0e099f52d 100644
--- a/app/assets/stylesheets/common/topic-timeline.scss
+++ b/app/assets/stylesheets/common/topic-timeline.scss
@@ -40,6 +40,10 @@
display: inherit;
}
}
+ .timeline-controls {
+ display: table-cell;
+ vertical-align: top;
+ }
}
&.timeline-fullscreen {
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index 03221e65ca..d5a9a12c0d 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -204,7 +204,7 @@ class PostsController < ApplicationController
if !guardian.public_send("can_edit?", post) &&
post.user_id == current_user.id &&
- post.edit_time_limit_expired?
+ post.edit_time_limit_expired?(current_user)
return render_json_error(I18n.t('too_late_to_edit'))
end
diff --git a/app/controllers/reviewables_controller.rb b/app/controllers/reviewables_controller.rb
index d2bed1f9e4..dffe6cd172 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/jobs/onceoff/clean_up_post_timings.rb b/app/jobs/onceoff/clean_up_post_timings.rb
new file mode 100644
index 0000000000..43c0e5caba
--- /dev/null
+++ b/app/jobs/onceoff/clean_up_post_timings.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Jobs
+ class CleanUpPostTimings < Jobs::Onceoff
+
+ # Remove post timings that are remnants of previous post moves
+ # or other shenanigans and don't reference a valid user or post anymore.
+ def execute_onceoff(args)
+ DB.exec <<~SQL
+ DELETE
+ FROM post_timings pt
+ WHERE NOT EXISTS(
+ SELECT 1
+ FROM posts p
+ WHERE p.topic_id = pt.topic_id
+ AND p.post_number = pt.post_number
+ )
+ SQL
+
+ DB.exec <<~SQL
+ DELETE
+ FROM post_timings pt
+ WHERE NOT EXISTS(
+ SELECT 1
+ FROM users u
+ WHERE pt.user_id = u.id
+ )
+ SQL
+ end
+ end
+end
diff --git a/app/models/concerns/limited_edit.rb b/app/models/concerns/limited_edit.rb
index cbe1554387..bfd21392b5 100644
--- a/app/models/concerns/limited_edit.rb
+++ b/app/models/concerns/limited_edit.rb
@@ -3,11 +3,22 @@
module LimitedEdit
extend ActiveSupport::Concern
- def edit_time_limit_expired?
- if created_at && SiteSetting.post_edit_time_limit.to_i > 0
- created_at < SiteSetting.post_edit_time_limit.to_i.minutes.ago
+ def edit_time_limit_expired?(user)
+ time_limit = user_time_limit(user)
+ if created_at && time_limit > 0
+ created_at < time_limit.minutes.ago
else
false
end
end
+
+ private
+
+ def user_time_limit(user)
+ if user.trust_level < 2
+ SiteSetting.post_edit_time_limit.to_i
+ else
+ SiteSetting.tl2_post_edit_time_limit.to_i
+ end
+ end
end
diff --git a/app/models/post_mover.rb b/app/models/post_mover.rb
index 3fcf4f3b53..65cc60f6f9 100644
--- a/app/models/post_mover.rb
+++ b/app/models/post_mover.rb
@@ -90,7 +90,10 @@ class PostMover
new_topic_title VARCHAR,
new_post_id INTEGER,
new_post_number INTEGER
- )
+ );
+
+ CREATE INDEX moved_posts_old_post_number ON moved_posts(old_post_number);
+ CREATE INDEX moved_posts_old_post_id ON moved_posts(old_post_id);
SQL
end
@@ -105,11 +108,8 @@ class PostMover
@move_map = {}
@reply_count = {}
posts.each_with_index do |post, offset|
- unless post.is_first_post?
- @move_map[post.post_number] = offset + max_post_number
- else
- @move_map[post.post_number] = 1
- end
+ @move_map[post.post_number] = offset + max_post_number
+
if post.reply_to_post_number.present?
@reply_count[post.reply_to_post_number] = (@reply_count[post.reply_to_post_number] || 0) + 1
end
@@ -131,6 +131,9 @@ class PostMover
update_reply_counts
move_first_post_replies
delete_post_replies
+ copy_first_post_timings
+ move_post_timings
+ copy_topic_users
end
def create_first_post(post)
@@ -260,7 +263,7 @@ class PostMover
DB.exec <<~SQL
UPDATE post_replies pr
SET post_id = mp.new_post_id
- FROM moved_posts mp, moved_posts mr
+ FROM moved_posts mp
WHERE mp.old_post_id <> mp.new_post_id AND pr.post_id = mp.old_post_id AND
EXISTS (SELECT 1 FROM moved_posts mr WHERE mr.new_post_id = pr.reply_id)
SQL
@@ -275,11 +278,124 @@ class PostMover
SQL
end
+ def copy_first_post_timings
+ DB.exec <<~SQL
+ INSERT INTO post_timings (topic_id, user_id, post_number, msecs)
+ SELECT mp.new_topic_id, pt.user_id, mp.new_post_number, pt.msecs
+ FROM post_timings pt
+ JOIN moved_posts mp ON (pt.topic_id = mp.old_topic_id AND pt.post_number = mp.old_post_number)
+ WHERE mp.old_post_id <> mp.new_post_id
+ ON CONFLICT (topic_id, post_number, user_id) DO UPDATE
+ SET msecs = GREATEST(post_timings.msecs, excluded.msecs)
+ SQL
+ end
+
+ def move_post_timings
+ DB.exec <<~SQL
+ UPDATE post_timings pt
+ SET topic_id = mp.new_topic_id,
+ post_number = mp.new_post_number
+ FROM moved_posts mp
+ WHERE pt.topic_id = mp.old_topic_id
+ AND pt.post_number = mp.old_post_number
+ AND mp.old_post_id = mp.new_post_id
+ SQL
+ end
+
+ def copy_topic_users
+ params = {
+ old_topic_id: original_topic.id,
+ new_topic_id: destination_topic.id,
+ old_highest_post_number: destination_topic.highest_post_number,
+ old_highest_staff_post_number: destination_topic.highest_staff_post_number
+ }
+
+ DB.exec(<<~SQL, params)
+ INSERT INTO topic_users(user_id, topic_id, posted, last_read_post_number, highest_seen_post_number,
+ last_emailed_post_number, first_visited_at, last_visited_at, notification_level,
+ notifications_changed_at, notifications_reason_id)
+ SELECT tu.user_id,
+ :new_topic_id AS topic_id,
+ EXISTS(
+ SELECT 1
+ FROM posts p
+ WHERE p.topic_id = tu.topic_id
+ AND p.user_id = tu.user_id
+ ) AS posted,
+ MAX(lr.new_post_number) AS last_read_post_number,
+ MAX(hs.new_post_number) AS highest_seen_post_number,
+ MAX(le.new_post_number) AS last_emailed_post_number,
+ GREATEST(tu.first_visited_at, t.created_at) AS first_visited_at,
+ GREATEST(tu.last_visited_at, t.created_at) AS last_visited_at,
+ tu.notification_level,
+ tu.notifications_changed_at,
+ tu.notifications_reason_id
+ FROM topic_users tu
+ JOIN topics t
+ ON (t.id = :new_topic_id)
+ LEFT OUTER JOIN moved_posts lr
+ ON (lr.old_topic_id = tu.topic_id AND lr.old_post_number <= tu.last_read_post_number)
+ LEFT OUTER JOIN moved_posts hs
+ ON (hs.old_topic_id = tu.topic_id AND hs.old_post_number <= tu.highest_seen_post_number)
+ LEFT OUTER JOIN moved_posts le
+ ON (le.old_topic_id = tu.topic_id AND le.old_post_number <= tu.last_emailed_post_number)
+ WHERE tu.topic_id = :old_topic_id
+ AND GREATEST(
+ tu.last_read_post_number,
+ tu.highest_seen_post_number,
+ tu.last_emailed_post_number
+ ) >= (SELECT MIN(old_post_number) FROM moved_posts)
+ GROUP BY tu.topic_id, tu.user_id, tu.first_visited_at, tu.last_visited_at, t.created_at, tu.notification_level,
+ tu.notifications_changed_at,
+ tu.notifications_reason_id
+ ON CONFLICT (topic_id, user_id) DO UPDATE
+ SET posted = excluded.posted,
+ last_read_post_number = CASE
+ WHEN topic_users.last_read_post_number = :old_highest_staff_post_number OR (
+ :old_highest_post_number < :old_highest_staff_post_number
+ AND topic_users.last_read_post_number = :old_highest_post_number
+ AND NOT EXISTS(SELECT 1
+ FROM users u
+ WHERE u.id = topic_users.user_id
+ AND (admin OR moderator))
+ ) THEN
+ GREATEST(topic_users.last_read_post_number,
+ excluded.last_read_post_number)
+ ELSE topic_users.last_read_post_number END,
+ highest_seen_post_number = CASE
+ WHEN topic_users.highest_seen_post_number = :old_highest_staff_post_number OR (
+ :old_highest_post_number < :old_highest_staff_post_number
+ AND topic_users.highest_seen_post_number = :old_highest_post_number
+ AND NOT EXISTS(SELECT 1
+ FROM users u
+ WHERE u.id = topic_users.user_id
+ AND (admin OR moderator))
+ ) THEN
+ GREATEST(topic_users.highest_seen_post_number,
+ excluded.highest_seen_post_number)
+ ELSE topic_users.highest_seen_post_number END,
+ last_emailed_post_number = CASE
+ WHEN topic_users.last_emailed_post_number = :old_highest_staff_post_number OR (
+ :old_highest_post_number < :old_highest_staff_post_number
+ AND topic_users.last_emailed_post_number = :old_highest_post_number
+ AND NOT EXISTS(SELECT 1
+ FROM users u
+ WHERE u.id = topic_users.user_id
+ AND (admin OR moderator))
+ ) THEN
+ GREATEST(topic_users.last_emailed_post_number,
+ excluded.last_emailed_post_number)
+ ELSE topic_users.last_emailed_post_number END,
+ first_visited_at = LEAST(topic_users.first_visited_at, excluded.first_visited_at),
+ last_visited_at = GREATEST(topic_users.last_visited_at, excluded.last_visited_at)
+ SQL
+ end
+
def update_statistics
destination_topic.update_statistics
original_topic.update_statistics
- TopicUser.update_post_action_cache(topic_id: original_topic.id, post_action_type: :bookmark)
- TopicUser.update_post_action_cache(topic_id: destination_topic.id, post_action_type: :bookmark)
+ TopicUser.update_post_action_cache(topic_id: original_topic.id)
+ TopicUser.update_post_action_cache(topic_id: destination_topic.id)
end
def update_user_actions
@@ -340,7 +456,7 @@ class PostMover
attrs = {}
attrs[:last_posted_at] = post.created_at
attrs[:last_post_user_id] = post.user_id
- attrs[:bumped_at] = post.created_at unless post.no_bump
+ attrs[:bumped_at] = Time.now
attrs[:updated_at] = Time.now
destination_topic.update_columns(attrs)
end
diff --git a/app/models/reviewable.rb b/app/models/reviewable.rb
index b0142d1eb3..dd7be14eb3 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 0063e2458a..14ba16ed48 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 0000000000..057c4dc3ce
--- /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 0000000000..caff0cee26
--- /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/app/services/inline_uploads.rb b/app/services/inline_uploads.rb
index 7a3204346e..039d5f3773 100644
--- a/app/services/inline_uploads.rb
+++ b/app/services/inline_uploads.rb
@@ -278,7 +278,7 @@ class InlineUploads
/(upload:\/\/([a-zA-Z0-9]+)[a-zA-Z0-9\.]*)/,
/(\/uploads\/short-url\/([a-zA-Z0-9]+)[a-zA-Z0-9\.]*)/,
/(#{base_url}\/uploads\/short-url\/([a-zA-Z0-9]+)[a-zA-Z0-9\.]*)/,
- /(\/uploads\/#{db}#{UPLOAD_REGEXP_PATTERN})/,
+ /(#{GlobalSetting.relative_url_root}\/uploads\/#{db}#{UPLOAD_REGEXP_PATTERN})/,
/(#{base_url}\/uploads\/#{db}#{UPLOAD_REGEXP_PATTERN})/,
]
diff --git a/app/services/user_destroyer.rb b/app/services/user_destroyer.rb
index bc3c1e3aac..dbdbc1daef 100644
--- a/app/services/user_destroyer.rb
+++ b/app/services/user_destroyer.rb
@@ -116,7 +116,7 @@ class UserDestroyer
end
# After the user is deleted, remove the reviewable
- if reviewable = Reviewable.pending.find_by(target: user)
+ if reviewable = ReviewableUser.pending.find_by(target: user)
reviewable.perform(@actor, :reject_user_delete)
end
diff --git a/app/views/embed/topics.html.erb b/app/views/embed/topics.html.erb
index 1c4f1de2d8..5a4660165a 100644
--- a/app/views/embed/topics.html.erb
+++ b/app/views/embed/topics.html.erb
@@ -16,7 +16,10 @@
<%- end %>
-
 %>)
+
 %>)
+
+ <%= t.user.username %>
+
">
<%= "#{I18n.t('embed.created')} #{time_ago_in_words(t.created_at, scope: :'datetime.distance_in_words_verbose')}" %>
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index cfcafba047..2b0eab6e19 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/locales/server.en.yml b/config/locales/server.en.yml
index 60f20e54c0..ff522b004e 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1361,7 +1361,8 @@ en:
editing_grace_period_max_diff: "Maximum number of character changes allowed in editing grace period, if more changed store another post revision (trust level 0 and 1)"
editing_grace_period_max_diff_high_trust: "Maximum number of character changes allowed in editing grace period, if more changed store another post revision (trust level 2 and up)"
staff_edit_locks_post: "Posts will be locked from editing if they are edited by staff members"
- post_edit_time_limit: "The author can edit their post for (n) minutes after posting. Set to 0 for forever."
+ post_edit_time_limit: "A tl0 or tl1 author can edit their post for (n) minutes after posting. Set to 0 for forever."
+ tl2_post_edit_time_limit: "A tl2 author can edit their post for (n) minutes after posting. Set to 0 for forever."
edit_history_visible_to_public: "Allow everyone to see previous versions of an edited post. When disabled, only staff members can view."
delete_removed_posts_after: "Posts removed by the author will be automatically deleted after (n) hours. If set to 0, posts will be deleted immediately."
max_image_width: "Maximum thumbnail width of images in a post"
diff --git a/config/routes.rb b/config/routes.rb
index 7b638267d6..328ad0e8b9 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/config/site_settings.yml b/config/site_settings.yml
index 67683bccef..c1a6cbfd74 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -700,6 +700,9 @@ posting:
type: category
default: ""
post_edit_time_limit:
+ default: 1440
+ max: 10080
+ tl2_post_edit_time_limit:
default: 43200
max: 525600
edit_history_visible_to_public:
diff --git a/docs/INSTALL-cloud.md b/docs/INSTALL-cloud.md
index edcaa30977..f7fb134a4d 100644
--- a/docs/INSTALL-cloud.md
+++ b/docs/INSTALL-cloud.md
@@ -12,7 +12,7 @@ Create your new cloud server, for example [on DigitalOcean][do]:
- The default of **New York** is a good choice for most US and European audiences. Or select a region that is geographically closer to your audience.
-- Enter your domain `discourse.example.com` as the name.
+- Enter your domain `discourse.example.com` as the Droplet name.
Create your new Droplet. You will receive an email with the root password. (However, if you know [how to use SSH keys](https://www.google.com/search?q=digitalocean+ssh+keys), you may not need a password to log in.)
diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb
index a8344e9fa8..25c46de3c5 100644
--- a/lib/guardian/post_guardian.rb
+++ b/lib/guardian/post_guardian.rb
@@ -158,7 +158,7 @@ module PostGuardian
return true
end
- return !post.edit_time_limit_expired?
+ return !post.edit_time_limit_expired?(@user)
end
false
@@ -238,7 +238,7 @@ module PostGuardian
if @user.has_trust_level?(SiteSetting.min_trust_to_allow_self_wiki) && is_my_own?(post)
return false if post.hidden?
- return !post.edit_time_limit_expired?
+ return !post.edit_time_limit_expired?(@user)
end
false
diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb
index e171d32d90..89f5fe6e54 100644
--- a/lib/guardian/topic_guardian.rb
+++ b/lib/guardian/topic_guardian.rb
@@ -106,7 +106,7 @@ module TopicGuardian
return false if topic.archived
is_my_own?(topic) &&
- !topic.edit_time_limit_expired? &&
+ !topic.edit_time_limit_expired?(user) &&
!Post.where(topic_id: topic.id, post_number: 1).where.not(locked_by_id: nil).exists?
end
diff --git a/lib/topic_query.rb b/lib/topic_query.rb
index 51d60f5c13..06a89f4f2c 100644
--- a/lib/topic_query.rb
+++ b/lib/topic_query.rb
@@ -863,6 +863,7 @@ class TopicQuery
list
end
+
def remove_muted_categories(list, user, opts = nil)
category_id = get_category_id(opts[:exclude]) if opts
@@ -885,6 +886,7 @@ class TopicQuery
list
end
+
def remove_muted_tags(list, user, opts = nil)
if user.nil? || !SiteSetting.tagging_enabled || SiteSetting.remove_muted_tags_from_latest == 'never'
return list
@@ -895,16 +897,9 @@ class TopicQuery
return list
end
- showing_tag = if opts[:filter]
- f = opts[:filter].split('/')
- f[0] == 'tags' ? f[1] : nil
- else
- nil
- end
-
# if viewing the topic list for a muted tag, show all the topics
- if showing_tag.present? && TagUser.lookup(user, :muted).joins(:tag).where('tags.name = ?', showing_tag).exists?
- return list
+ if !opts[:no_tags] && opts[:tags].present?
+ return list if TagUser.lookup(user, :muted).joins(:tag).where('tags.name = ?', opts[:tags].first).exists?
end
if SiteSetting.remove_muted_tags_from_latest == 'always'
diff --git a/lib/version.rb b/lib/version.rb
index 37546b6ec0..66d3e9942e 100644
--- a/lib/version.rb
+++ b/lib/version.rb
@@ -9,7 +9,7 @@ module Discourse
MAJOR = 2
MINOR = 4
TINY = 0
- PRE = 'beta3'
+ PRE = 'beta4'
STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.')
end
diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb
index 926bf310e8..0dddab7281 100644
--- a/spec/components/guardian_spec.rb
+++ b/spec/components/guardian_spec.rb
@@ -1389,32 +1389,65 @@ describe Guardian do
expect(Guardian.new(post.user).can_edit?(post)).to be_truthy
end
- context 'post is older than post_edit_time_limit' do
- let(:old_post) { build(:post, topic: topic, user: topic.user, created_at: 6.minutes.ago) }
+ describe 'post edit time limits' do
+ context 'post is older than post_edit_time_limit' do
+ let(:old_post) { build(:post, topic: topic, user: topic.user, created_at: 6.minutes.ago) }
- before do
- SiteSetting.post_edit_time_limit = 5
+ before do
+ topic.user.update_columns(trust_level: 1)
+ SiteSetting.post_edit_time_limit = 5
+ end
+
+ it 'returns false to the author of the post' do
+ expect(Guardian.new(old_post.user).can_edit?(old_post)).to be_falsey
+ end
+
+ it 'returns true as a moderator' do
+ expect(Guardian.new(moderator).can_edit?(old_post)).to eq(true)
+ end
+
+ it 'returns true as an admin' do
+ expect(Guardian.new(admin).can_edit?(old_post)).to eq(true)
+ end
+
+ it 'returns false for another regular user trying to edit your post' do
+ expect(Guardian.new(coding_horror).can_edit?(old_post)).to be_falsey
+ end
+
+ it 'returns true for another regular user trying to edit a wiki post' do
+ old_post.wiki = true
+ expect(Guardian.new(coding_horror).can_edit?(old_post)).to be_truthy
+ end
end
- it 'returns false to the author of the post' do
- expect(Guardian.new(old_post.user).can_edit?(old_post)).to be_falsey
- end
+ context 'post is older than tl2_post_edit_time_limit' do
+ let(:old_post) { build(:post, topic: topic, user: topic.user, created_at: 12.minutes.ago) }
- it 'returns true as a moderator' do
- expect(Guardian.new(moderator).can_edit?(old_post)).to eq(true)
- end
+ before do
+ topic.user.update_columns(trust_level: 2)
+ SiteSetting.tl2_post_edit_time_limit = 10
+ end
- it 'returns true as an admin' do
- expect(Guardian.new(admin).can_edit?(old_post)).to eq(true)
- end
+ it 'returns false to the author of the post' do
+ expect(Guardian.new(old_post.user).can_edit?(old_post)).to be_falsey
+ end
- it 'returns false for another regular user trying to edit your post' do
- expect(Guardian.new(coding_horror).can_edit?(old_post)).to be_falsey
- end
+ it 'returns true as a moderator' do
+ expect(Guardian.new(moderator).can_edit?(old_post)).to eq(true)
+ end
- it 'returns true for another regular user trying to edit a wiki post' do
- old_post.wiki = true
- expect(Guardian.new(coding_horror).can_edit?(old_post)).to be_truthy
+ it 'returns true as an admin' do
+ expect(Guardian.new(admin).can_edit?(old_post)).to eq(true)
+ end
+
+ it 'returns false for another regular user trying to edit your post' do
+ expect(Guardian.new(coding_horror).can_edit?(old_post)).to be_falsey
+ end
+
+ it 'returns true for another regular user trying to edit a wiki post' do
+ old_post.wiki = true
+ expect(Guardian.new(coding_horror).can_edit?(old_post)).to be_truthy
+ end
end
end
@@ -2854,7 +2887,7 @@ describe Guardian do
let(:old_post) { build(:post, user: trust_level_2, created_at: 6.minutes.ago) }
before do
SiteSetting.min_trust_to_allow_self_wiki = 2
- SiteSetting.post_edit_time_limit = 5
+ SiteSetting.tl2_post_edit_time_limit = 5
end
it 'returns false when user satisfies trust level and owns the post' do
diff --git a/spec/fabricators/post_fabricator.rb b/spec/fabricators/post_fabricator.rb
index 8f0fef9259..33c7598568 100644
--- a/spec/fabricators/post_fabricator.rb
+++ b/spec/fabricators/post_fabricator.rb
@@ -162,3 +162,7 @@ Fabricator(:post_via_email, from: :post) do
incoming_email.user = post.user
end
end
+
+Fabricator(:whisper, from: :post) do
+ post_type Post.types[:whisper]
+end
diff --git a/spec/models/post_mover_spec.rb b/spec/models/post_mover_spec.rb
index 80a5f97d6b..77fdbfac95 100644
--- a/spec/models/post_mover_spec.rb
+++ b/spec/models/post_mover_spec.rb
@@ -114,6 +114,17 @@ describe PostMover do
before do
TopicUser.update_last_read(user, topic.id, p4.post_number, p4.post_number, 0)
TopicLink.extract_from(p2)
+
+ freeze_time Time.now
+ end
+
+ def create_post_timing(post, user, msecs)
+ PostTiming.create!(
+ topic_id: post.topic_id,
+ user_id: user.id,
+ post_number: post.post_number,
+ msecs: msecs
+ )
end
context "post replies" do
@@ -207,7 +218,7 @@ describe PostMover do
p4.reload
expect(new_topic.last_post_user_id).to eq(p4.user_id)
expect(new_topic.last_posted_at).to eq(p4.created_at)
- expect(new_topic.bumped_at).to eq(p4.created_at)
+ expect(new_topic.bumped_at).to be_within(1.second).of(Time.now)
p2.reload
expect(p2.sort_order).to eq(1)
@@ -234,7 +245,7 @@ describe PostMover do
notification_level: TopicUser.notification_levels[:watching],
notifications_reason_id: TopicUser.notification_reasons[:created_topic]
)).to eq(true)
- expect(TopicUser.exists?(user_id: user, topic_id: new_topic.id)).to eq(false)
+ expect(TopicUser.exists?(user_id: user, topic_id: new_topic.id)).to eq(true)
end
it "moving all posts will close the topic" do
@@ -334,11 +345,113 @@ describe PostMover do
expect(Notification.exists?(user_notification.id)).to eq(false)
expect(Notification.exists?(admin_notification.id)).to eq(true)
end
+
+ it "moves post timings" do
+ some_user = Fabricate(:user)
+ create_post_timing(p1, some_user, 500)
+ create_post_timing(p2, some_user, 1000)
+ create_post_timing(p3, some_user, 1500)
+ create_post_timing(p4, some_user, 750)
+
+ new_topic = topic.move_posts(user, [p1.id, p4.id], title: "new testing topic name")
+
+ expect(PostTiming.where(topic_id: topic.id, user_id: some_user.id).pluck(:post_number, :msecs))
+ .to contain_exactly([1, 500], [2, 1000], [3, 1500])
+
+ expect(PostTiming.where(topic_id: new_topic.id, user_id: some_user.id).pluck(:post_number, :msecs))
+ .to contain_exactly([1, 500], [2, 750])
+ end
+
+ context "read state and other stats per user" do
+ def create_topic_user(user, opts = {})
+ notification_level = opts.delete(:notification_level) || :regular
+
+ Fabricate(:topic_user, opts.merge(
+ notification_level: TopicUser.notification_levels[notification_level],
+ topic: topic,
+ user: user
+ ))
+ end
+
+ fab!(:user1) { Fabricate(:user) }
+ fab!(:user2) { Fabricate(:user) }
+ fab!(:user3) { Fabricate(:user) }
+ fab!(:admin1) { Fabricate(:admin) }
+ fab!(:admin2) { Fabricate(:admin) }
+
+ it "correctly moves topic_user records" do
+ create_topic_user(
+ user1,
+ last_read_post_number: 4,
+ highest_seen_post_number: 4,
+ last_emailed_post_number: 3,
+ notification_level: :tracking
+ )
+ create_topic_user(
+ user3,
+ last_read_post_number: 1,
+ highest_seen_post_number: 2,
+ last_emailed_post_number: 4,
+ notification_level: :watching
+ )
+
+ new_topic = topic.move_posts(user, [p1.id, p2.id], title: "new testing topic name")
+
+ expect(TopicUser.where(topic_id: topic.id).count).to eq(3)
+ expect(TopicUser.find_by(topic: topic, user: user))
+ .to have_attributes(
+ last_read_post_number: 4,
+ highest_seen_post_number: 4,
+ last_emailed_post_number: nil,
+ notification_level: TopicUser.notification_levels[:tracking]
+ )
+ expect(TopicUser.find_by(topic: topic, user: user1))
+ .to have_attributes(
+ last_read_post_number: 4,
+ highest_seen_post_number: 4,
+ last_emailed_post_number: 3,
+ notification_level: TopicUser.notification_levels[:tracking]
+ )
+ expect(TopicUser.find_by(topic: topic, user: user3))
+ .to have_attributes(
+ last_read_post_number: 1,
+ highest_seen_post_number: 2,
+ last_emailed_post_number: 4,
+ notification_level: TopicUser.notification_levels[:watching]
+ )
+
+ expect(TopicUser.where(topic_id: new_topic.id).count).to eq(3)
+ expect(TopicUser.find_by(topic: new_topic, user: user))
+ .to have_attributes(
+ last_read_post_number: 1,
+ highest_seen_post_number: 1,
+ last_emailed_post_number: nil,
+ notification_level: TopicUser.notification_levels[:watching],
+ posted: true
+ )
+ expect(TopicUser.find_by(topic: new_topic, user: user1))
+ .to have_attributes(
+ last_read_post_number: 2,
+ highest_seen_post_number: 2,
+ last_emailed_post_number: 2,
+ notification_level: TopicUser.notification_levels[:tracking],
+ posted: false
+ )
+ expect(TopicUser.find_by(topic: new_topic, user: user3))
+ .to have_attributes(
+ last_read_post_number: 1,
+ highest_seen_post_number: 2,
+ last_emailed_post_number: 2,
+ notification_level: TopicUser.notification_levels[:watching],
+ posted: false
+ )
+ end
+ end
end
context "to an existing topic" do
fab!(:destination_topic) { Fabricate(:topic, user: another_user) }
- fab!(:destination_op) { Fabricate(:post, topic: destination_topic, user: another_user) }
+ fab!(:destination_op) { Fabricate(:post, topic: destination_topic, user: another_user, created_at: 1.day.ago) }
it "works correctly" do
topic.expects(:add_moderator_post).once
@@ -355,7 +468,7 @@ describe PostMover do
p4.reload
expect(moved_to.last_post_user_id).to eq(p4.user_id)
expect(moved_to.last_posted_at).to eq(p4.created_at)
- expect(moved_to.bumped_at).to eq(p4.created_at)
+ expect(moved_to.bumped_at).to be_within(1.second).of(Time.now)
# Posts should be re-ordered
p2.reload
@@ -440,6 +553,189 @@ describe PostMover do
expect(Notification.exists?(user_notification.id)).to eq(false)
expect(Notification.exists?(admin_notification.id)).to eq(true)
end
+
+ it "moves post timings" do
+ some_user = Fabricate(:user)
+ create_post_timing(p1, some_user, 500)
+ create_post_timing(p2, some_user, 1000)
+ create_post_timing(p3, some_user, 1500)
+ create_post_timing(p4, some_user, 750)
+
+ moved_to = topic.move_posts(user, [p1.id, p4.id], destination_topic_id: destination_topic.id)
+
+ expect(PostTiming.where(topic_id: topic.id, user_id: some_user.id).pluck(:post_number, :msecs))
+ .to contain_exactly([1, 500], [2, 1000], [3, 1500])
+
+ expect(PostTiming.where(topic_id: moved_to.id, user_id: some_user.id).pluck(:post_number, :msecs))
+ .to contain_exactly([2, 500], [3, 750])
+ end
+
+ context "read state and other stats per user" do
+ def create_topic_user(user, topic, opts = {})
+ notification_level = opts.delete(:notification_level) || :regular
+
+ Fabricate(:topic_user, opts.merge(
+ notification_level: TopicUser.notification_levels[notification_level],
+ topic: topic,
+ user: user
+ ))
+ end
+
+ fab!(:user1) { Fabricate(:user) }
+ fab!(:user2) { Fabricate(:user) }
+ fab!(:user3) { Fabricate(:user) }
+ fab!(:admin1) { Fabricate(:admin) }
+ fab!(:admin2) { Fabricate(:admin) }
+
+ it "leaves post numbers unchanged when they were lower then the topic's highest post number" do
+ Fabricate(:post, topic: destination_topic)
+ Fabricate(:whisper, topic: destination_topic)
+
+ destination_topic.reload
+ expect(destination_topic.highest_post_number).to eq(2)
+ expect(destination_topic.highest_staff_post_number).to eq(3)
+
+ create_topic_user(
+ user1, topic,
+ last_read_post_number: 3,
+ highest_seen_post_number: 3,
+ last_emailed_post_number: 3
+ )
+ create_topic_user(
+ user1, destination_topic,
+ last_read_post_number: 1,
+ highest_seen_post_number: 2,
+ last_emailed_post_number: 1
+ )
+
+ create_topic_user(
+ user2, topic,
+ last_read_post_number: 3,
+ highest_seen_post_number: 3,
+ last_emailed_post_number: 3
+ )
+ create_topic_user(
+ user2, destination_topic,
+ last_read_post_number: 2,
+ highest_seen_post_number: 1,
+ last_emailed_post_number: 2
+ )
+
+ create_topic_user(
+ admin1, topic,
+ last_read_post_number: 3,
+ highest_seen_post_number: 3,
+ last_emailed_post_number: 3
+ )
+ create_topic_user(
+ admin1, destination_topic,
+ last_read_post_number: 2,
+ highest_seen_post_number: 3,
+ last_emailed_post_number: 1
+ )
+
+ create_topic_user(
+ admin2, topic,
+ last_read_post_number: 3,
+ highest_seen_post_number: 3,
+ last_emailed_post_number: 3
+ )
+ create_topic_user(
+ admin2, destination_topic,
+ last_read_post_number: 3,
+ highest_seen_post_number: 2,
+ last_emailed_post_number: 3
+ )
+
+ moved_to_topic = topic.move_posts(user, [p1.id, p2.id], destination_topic_id: destination_topic.id)
+
+ expect(TopicUser.find_by(topic: moved_to_topic, user: user1))
+ .to have_attributes(
+ last_read_post_number: 1,
+ highest_seen_post_number: 5,
+ last_emailed_post_number: 1
+ )
+
+ expect(TopicUser.find_by(topic: moved_to_topic, user: user2))
+ .to have_attributes(
+ last_read_post_number: 5,
+ highest_seen_post_number: 1,
+ last_emailed_post_number: 5
+ )
+
+ expect(TopicUser.find_by(topic: moved_to_topic, user: admin1))
+ .to have_attributes(
+ last_read_post_number: 2,
+ highest_seen_post_number: 5,
+ last_emailed_post_number: 1
+ )
+
+ expect(TopicUser.find_by(topic: moved_to_topic, user: admin2))
+ .to have_attributes(
+ last_read_post_number: 5,
+ highest_seen_post_number: 2,
+ last_emailed_post_number: 5
+ )
+ end
+
+ it "correctly updates existing topic_user records" do
+ destination_topic.update!(created_at: 1.day.ago)
+
+ original_topic_user1 = create_topic_user(
+ user1, topic,
+ highest_seen_post_number: 5,
+ first_visited_at: 5.hours.ago,
+ last_visited_at: 30.minutes.ago,
+ notification_level: :tracking
+ ).reload
+ destination_topic_user1 = create_topic_user(
+ user1, destination_topic,
+ highest_seen_post_number: 5,
+ first_visited_at: 7.hours.ago,
+ last_visited_at: 2.hours.ago,
+ notification_level: :watching
+ ).reload
+
+ original_topic_user2 = create_topic_user(
+ user2, topic,
+ highest_seen_post_number: 5,
+ first_visited_at: 3.hours.ago,
+ last_visited_at: 1.hour.ago,
+ notification_level: :watching
+ ).reload
+ destination_topic_user2 = create_topic_user(
+ user2, destination_topic,
+ highest_seen_post_number: 5,
+ first_visited_at: 2.hours.ago,
+ last_visited_at: 1.hour.ago,
+ notification_level: :tracking
+ ).reload
+
+ new_topic = topic.move_posts(user, [p1.id, p2.id], destination_topic_id: destination_topic.id)
+
+ expect(TopicUser.find_by(topic: new_topic, user: user))
+ .to have_attributes(
+ notification_level: TopicUser.notification_levels[:tracking],
+ posted: true
+ )
+
+ expect(TopicUser.find_by(topic: new_topic, user: user1))
+ .to have_attributes(
+ first_visited_at: destination_topic_user1.first_visited_at,
+ last_visited_at: original_topic_user1.last_visited_at,
+ notification_level: destination_topic_user1.notification_level,
+ posted: false
+ )
+
+ expect(TopicUser.find_by(topic: new_topic, user: user2))
+ .to have_attributes(
+ first_visited_at: original_topic_user2.first_visited_at,
+ last_visited_at: destination_topic_user2.last_visited_at,
+ notification_level: destination_topic_user2.notification_level,
+ posted: false
+ )
+ end
+ end
end
context "to a message" do
@@ -465,7 +761,7 @@ describe PostMover do
p4.reload
expect(new_topic.last_post_user_id).to eq(p4.user_id)
expect(new_topic.last_posted_at).to eq(p4.created_at)
- expect(new_topic.bumped_at).to eq(p4.created_at)
+ expect(new_topic.bumped_at).to be_within(1.second).of(Time.now)
p2.reload
expect(p2.sort_order).to eq(1)
diff --git a/spec/requests/posts_controller_spec.rb b/spec/requests/posts_controller_spec.rb
index 7a8c83f97c..7e10e1a4ae 100644
--- a/spec/requests/posts_controller_spec.rb
+++ b/spec/requests/posts_controller_spec.rb
@@ -313,8 +313,26 @@ describe PostsController do
sign_in(user)
end
- it 'does not allow to update when edit time limit expired' do
+ it 'does not allow TL0 or TL1 to update when edit time limit expired' do
SiteSetting.post_edit_time_limit = 5
+ SiteSetting.tl2_post_edit_time_limit = 30
+
+ post = Fabricate(:post, created_at: 10.minutes.ago, user: user)
+
+ user.update_columns(trust_level: 1)
+
+ put "/posts/#{post.id}.json", params: update_params
+
+ expect(response.status).to eq(422)
+ expect(JSON.parse(response.body)['errors']).to include(I18n.t('too_late_to_edit'))
+ end
+
+ it 'does not allow TL2 to update when edit time limit expired' do
+ SiteSetting.post_edit_time_limit = 12
+ SiteSetting.tl2_post_edit_time_limit = 8
+
+ user.update_columns(trust_level: 2)
+
post = Fabricate(:post, created_at: 10.minutes.ago, user: user)
put "/posts/#{post.id}.json", params: update_params
diff --git a/spec/requests/reviewables_controller_spec.rb b/spec/requests/reviewables_controller_spec.rb
index d83135e79f..dba29199b0 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
diff --git a/spec/requests/tags_controller_spec.rb b/spec/requests/tags_controller_spec.rb
index ea96873e7b..26bf959170 100644
--- a/spec/requests/tags_controller_spec.rb
+++ b/spec/requests/tags_controller_spec.rb
@@ -207,6 +207,11 @@ describe TagsController do
end
context 'tagging enabled' do
+ def parse_topic_ids
+ JSON.parse(response.body)["topic_list"]["topics"]
+ .map { |topic| topic["id"] }
+ end
+
it "can filter by tag" do
get "/tags/#{tag.name}/l/latest.json"
expect(response.status).to eq(200)
@@ -221,9 +226,7 @@ describe TagsController do
expect(response.status).to eq(200)
- topic_ids = JSON.parse(response.body)["topic_list"]["topics"]
- .map { |topic| topic["id"] }
-
+ topic_ids = parse_topic_ids
expect(topic_ids).to include(all_tag_topic.id)
expect(topic_ids).to include(multi_tag_topic.id)
expect(topic_ids).to_not include(single_tag_topic.id)
@@ -238,9 +241,7 @@ describe TagsController do
expect(response.status).to eq(200)
- topic_ids = JSON.parse(response.body)["topic_list"]["topics"]
- .map { |topic| topic["id"] }
-
+ topic_ids = parse_topic_ids
expect(topic_ids).to include(all_tag_topic.id)
expect(topic_ids).to_not include(multi_tag_topic.id)
expect(topic_ids).to_not include(single_tag_topic.id)
@@ -255,9 +256,7 @@ describe TagsController do
expect(response.status).to eq(200)
- topic_ids = JSON.parse(response.body)["topic_list"]["topics"]
- .map { |topic| topic["id"] }
-
+ topic_ids = parse_topic_ids
expect(topic_ids).to_not include(single_tag_topic.id)
end
@@ -288,17 +287,53 @@ describe TagsController do
expect(response.status).to eq(200)
- topic_ids = JSON.parse(response.body)["topic_list"]["topics"]
- .map { |topic| topic["id"] }
-
+ topic_ids = parse_topic_ids
expect(topic_ids).to include(t.id)
end
- it "can filter by bookmarked" do
- sign_in(Fabricate(:user))
- get "/tags/#{tag.name}/l/bookmarks.json"
+ context "when logged in" do
+ fab!(:user) { Fabricate(:user) }
- expect(response.status).to eq(200)
+ before do
+ sign_in(user)
+ end
+
+ it "can filter by bookmarked" do
+ get "/tags/#{tag.name}/l/bookmarks.json"
+
+ expect(response.status).to eq(200)
+ end
+
+ context "muted tags" do
+ before do
+ TagUser.create!(
+ user_id: user.id,
+ tag_id: tag.id,
+ notification_level: CategoryUser.notification_levels[:muted]
+ )
+ end
+
+ it "includes topics when filtered by muted tag" do
+ single_tag_topic
+
+ get "/tags/#{tag.name}/l/latest.json"
+ expect(response.status).to eq(200)
+
+ topic_ids = parse_topic_ids
+ expect(topic_ids).to include(single_tag_topic.id)
+ end
+
+ it "includes topics when filtered by category and muted tag" do
+ category = Fabricate(:category)
+ single_tag_topic.update!(category: category)
+
+ get "/tags/c/#{category.slug}/#{tag.name}/l/latest.json"
+ expect(response.status).to eq(200)
+
+ topic_ids = parse_topic_ids
+ expect(topic_ids).to include(single_tag_topic.id)
+ end
+ end
end
end
end
diff --git a/spec/services/inline_uploads_spec.rb b/spec/services/inline_uploads_spec.rb
index 9a39d93e2c..41646c341e 100644
--- a/spec/services/inline_uploads_spec.rb
+++ b/spec/services/inline_uploads_spec.rb
@@ -206,6 +206,28 @@ RSpec.describe InlineUploads do
MD
end
+ context "subfolder" do
+ before do
+ global_setting :relative_url_root, "/community"
+ ActionController::Base.config.relative_url_root = "/community"
+ end
+
+ after do
+ ActionController::Base.config.relative_url_root = nil
+ end
+
+ it "should correct subfolder images" do
+
+ md = <<~MD
+

+ MD
+
+ expect(InlineUploads.process(md)).to eq(<<~MD)
+ 
+ MD
+ end
+ end
+
it "should correct raw image URLs to the short url and paths" do
md = <<~MD
#{Discourse.base_url}#{upload.url}
diff --git a/spec/services/user_destroyer_spec.rb b/spec/services/user_destroyer_spec.rb
index 3b89b47db7..2fbefd9acf 100644
--- a/spec/services/user_destroyer_spec.rb
+++ b/spec/services/user_destroyer_spec.rb
@@ -100,6 +100,16 @@ describe UserDestroyer do
end
end
+ context "with a reviewable user" do
+ let(:reviewable) { Fabricate(:reviewable, created_by: admin) }
+
+ it 'sets the reviewable user as rejected' do
+ UserDestroyer.new(admin).destroy(reviewable.target)
+
+ expect(reviewable.reload.status).to eq(Reviewable.statuses[:rejected])
+ end
+ end
+
context "with a directory item record" do
it "removes the directory item" do