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}} + + + + + + {{#each reviewableExplanation.scores as |s|}} + + + + + {{/each}} + + + + +
{{i18n "review.explain.formula"}}{{i18n "review.explain.subtotal"}}
+ {{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}}
{{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) + ![](#{upload.short_url}) + 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