Version bump

This commit is contained in:
Robin Ward 2019-09-06 16:09:03 -04:00
commit 85d6de7b00
45 changed files with 960 additions and 98 deletions

View File

@ -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`;
}
});

View File

@ -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;
}
});

View File

@ -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: {} };

View File

@ -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));
}
});

View File

@ -0,0 +1,5 @@
import { registerUnbound } from "discourse-common/lib/helpers";
registerUnbound("float", function(n) {
return parseFloat(n).toFixed(1);
});

View File

@ -139,7 +139,7 @@ const Group = RestModel.extend({
@computed("visibility_level")
isPrivate(visibilityLevel) {
return ![0, 1].includes(visibilityLevel);
return visibilityLevel > 1;
},
@observes("isPrivate", "canEveryoneMention")

View File

@ -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}`
);

View File

@ -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({

View File

@ -10,6 +10,9 @@
<span class='status'>
{{reviewable-status reviewable.status}}
</span>
<a {{action "explainReviewable" reviewable}} class='explain' title={{i18n "review.explain.why"}}>
{{d-icon "question-circle"}}
</a>
</div>
<div class='reviewable-contents'>

View File

@ -0,0 +1,11 @@
{{#if value}}
<span class='score-value'>
<span class='score-number'>{{float value}}</span>
{{#if label}}
<span class='score-value-type' title={{i18n (concat "review.explain." label ".title")}}>
{{i18n (concat "review.explain." label ".name")}}
</span>
{{/if}}
</span>
<span class='op'>+</span>
{{/if}}

View File

@ -0,0 +1,47 @@
{{#d-modal-body class="explain-reviewable"}}
{{#conditional-loading-spinner condition=loading}}
<table>
<tr>
<th>{{i18n "review.explain.formula"}}</th>
<th>{{i18n "review.explain.subtotal"}}</th>
</tr>
{{#each reviewableExplanation.scores as |s|}}
<tr>
<td>
{{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=""}}
</td>
<td class='sum'>{{float s.score}}</td>
</tr>
{{/each}}
<tr class="total">
<td>{{i18n "review.explain.total"}}</td>
<td class='sum'>{{float reviewableExplanation.total_score}}</td>
</tr>
</table>
<table class='thresholds'>
<tr>
<td>{{i18n "review.explain.min_score_visibility"}}</td>
<td class='sum'>
{{float reviewableExplanation.min_score_visibility}}
</td>
</tr>
<tr>
<td>{{i18n "review.explain.score_to_hide"}}</td>
<td class='sum'>
{{float reviewableExplanation.hide_post_score}}
</td>
</tr>
</table>
{{/conditional-loading-spinner}}
{{/d-modal-body}}
<div class="modal-footer">
{{d-button action=(route-action "closeModal") label="close"}}
</div>

View File

@ -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

View File

@ -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
};
}
});

View File

@ -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;
}
}

View File

@ -20,6 +20,9 @@
}
}
}
.explain {
margin-left: 0.5em;
}
.nav-pills {
margin-bottom: 1em;

View File

@ -40,6 +40,10 @@
display: inherit;
}
}
.timeline-controls {
display: table-cell;
vertical-align: top;
}
}
&.timeline-fullscreen {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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})/,
]

View File

@ -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

View File

@ -16,7 +16,10 @@
</div>
<%- end %>
<div class='topic-author-avatar-timestamp'>
<img src="<%= t.user.avatar_template.gsub('{size}', '20') %>">
<img src="<%= t.user.avatar_template.gsub('{size}', '40') %>">
<span class="topic-author-username">
<%= t.user.username %>
</span>
<span class="topic-created-at" title="<%= t.created_at.strftime("%B %e, %Y %l:%M%P") %>">
<%= "#{I18n.t('embed.created')} #{time_ago_in_words(t.created_at, scope: :'datetime.distance_in_words_verbose')}" %>
</span>

View File

@ -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."

View File

@ -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"

View File

@ -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"

View File

@ -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:

View File

@ -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.)

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -9,7 +9,7 @@ module Discourse
MAJOR = 2
MINOR = 4
TINY = 0
PRE = 'beta3'
PRE = 'beta4'
STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.')
end

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
<img src="/community#{upload.url}">
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}

View File

@ -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