FEATURE: Staff members can lock posts

Locking a post prevents it from being edited. This is useful if the user
has posted something which has been edited out, and the staff members don't
want them to be able to edit it back in again.
This commit is contained in:
Robin Ward 2018-01-25 15:38:40 -05:00
parent 76317957ed
commit 6b04967e2f
19 changed files with 218 additions and 5 deletions

View File

@ -518,6 +518,14 @@ export default Ember.Controller.extend(BufferedContent, {
this.send('changeOwner'); this.send('changeOwner');
}, },
lockPost(post) {
return post.updatePostField('locked', true);
},
unlockPost(post) {
return post.updatePostField('locked', false);
},
grantBadge(post) { grantBadge(post) {
this.set("selectedPostIds", [post.id]); this.set("selectedPostIds", [post.id]);
this.send('showGrantBadgeModal'); this.send('showGrantBadgeModal');

View File

@ -77,6 +77,7 @@ export function transformBasicPost(post) {
cooked_hidden: !!post.cooked_hidden, cooked_hidden: !!post.cooked_hidden,
expandablePost: false, expandablePost: false,
replyCount: post.reply_count, replyCount: post.reply_count,
locked: post.locked
}; };
_additionalAttributes.forEach(a => postAtts[a] = post[a]); _additionalAttributes.forEach(a => postAtts[a] = post[a]);

View File

@ -175,6 +175,8 @@
rebakePost=(action "rebakePost") rebakePost=(action "rebakePost")
changePostOwner=(action "changePostOwner") changePostOwner=(action "changePostOwner")
grantBadge=(action "grantBadge") grantBadge=(action "grantBadge")
lockPost=(action "lockPost")
unlockPost=(action "unlockPost")
unhidePost=(action "unhidePost") unhidePost=(action "unhidePost")
replyToPost=(action "replyToPost") replyToPost=(action "replyToPost")
toggleWiki=(action "toggleWiki") toggleWiki=(action "toggleWiki")

View File

@ -73,6 +73,15 @@ export function buildManageButtons(attrs, currentUser) {
action: 'grantBadge', action: 'grantBadge',
className: 'grant-badge' className: 'grant-badge'
}); });
const action = attrs.locked ? "unlock" : "lock";
contents.push({
icon: action,
label: `post.controls.${action}_post`,
action: `${action}Post`,
title: `post.controls.${action}_post_description`,
className: `${action}-post`
});
} }
if (attrs.canManage || attrs.canWiki) { if (attrs.canManage || attrs.canWiki) {

View File

@ -7,6 +7,7 @@ import { h } from 'virtual-dom';
import DiscourseURL from 'discourse/lib/url'; import DiscourseURL from 'discourse/lib/url';
import { dateNode } from 'discourse/helpers/node'; import { dateNode } from 'discourse/helpers/node';
import { translateSize, avatarUrl, formatUsername } from 'discourse/lib/utilities'; import { translateSize, avatarUrl, formatUsername } from 'discourse/lib/utilities';
import hbs from 'discourse/widgets/hbs-compiler';
export function avatarImg(wanted, attrs) { export function avatarImg(wanted, attrs) {
const size = translateSize(wanted); const size = translateSize(wanted);
@ -139,6 +140,12 @@ createWidget('post-avatar', {
} }
}); });
createWidget('post-locked-indicator', {
tagName: 'div.post-info.post-locked',
template: hbs`{{d-icon "lock"}}`,
title: () => I18n.t("post.locked")
});
createWidget('post-email-indicator', { createWidget('post-email-indicator', {
tagName: 'div.post-info.via-email', tagName: 'div.post-info.via-email',
@ -207,6 +214,10 @@ createWidget('post-meta-data', {
result.push(this.attach('post-email-indicator', attrs)); result.push(this.attach('post-email-indicator', attrs));
} }
if (attrs.locked) {
result.push(this.attach('post-locked-indicator', attrs));
}
if (attrs.version > 1 || attrs.wiki) { if (attrs.version > 1 || attrs.wiki) {
result.push(this.attach('post-edits-indicator', attrs)); result.push(this.attach('post-edits-indicator', attrs));
} }

View File

@ -320,8 +320,11 @@ aside.quote {
} }
.post-info { .post-info {
&.via-email, &.whisper { &.via-email, &.whisper {
line-height: $line-height-medium; line-height: $line-height-medium;
}
&.via-email, &.whisper, &.post-locked {
margin-right: 5px; margin-right: 5px;
.d-icon { .d-icon {
font-size: $font-0; font-size: $font-0;

View File

@ -4,12 +4,34 @@ require_dependency 'post_destroyer'
require_dependency 'post_merger' require_dependency 'post_merger'
require_dependency 'distributed_memoizer' require_dependency 'distributed_memoizer'
require_dependency 'new_post_result_serializer' require_dependency 'new_post_result_serializer'
require_dependency 'post_locker'
class PostsController < ApplicationController class PostsController < ApplicationController
before_action :ensure_logged_in, except: [:show, :replies, :by_number, :short_link, :reply_history, :replyIids, :revisions, :latest_revision, :expand_embed, :markdown_id, :markdown_num, :cooked, :latest, :user_posts_feed] before_action :ensure_logged_in, except: [
:show,
:replies,
:by_number,
:short_link,
:reply_history,
:replyIids,
:revisions,
:latest_revision,
:expand_embed,
:markdown_id,
:markdown_num,
:cooked,
:latest,
:user_posts_feed
]
skip_before_action :preload_json, :check_xhr, only: [:markdown_id, :markdown_num, :short_link, :latest, :user_posts_feed] skip_before_action :preload_json, :check_xhr, only: [
:markdown_id,
:markdown_num,
:short_link,
:latest,
:user_posts_feed
]
def markdown_id def markdown_id
markdown Post.find(params[:id].to_i) markdown Post.find(params[:id].to_i)
@ -381,6 +403,13 @@ class PostsController < ApplicationController
render_json_dump(result) render_json_dump(result)
end end
def locked
post = find_post_from_params
locker = PostLocker.new(post, current_user)
params[:locked] === "true" ? locker.lock : locker.unlock
render_json_dump(locked: post.locked?)
end
def bookmark def bookmark
if params[:bookmarked] == "true" if params[:bookmarked] == "true"
post = find_post_from_params post = find_post_from_params

View File

@ -735,6 +735,10 @@ class Post < ActiveRecord::Base
UserActionCreator.log_post(self) UserActionCreator.log_post(self)
end end
def locked?
locked_by_id.present?
end
private private
def parse_quote_into_arguments(quote) def parse_quote_into_arguments(quote)

View File

@ -63,7 +63,9 @@ class UserHistory < ActiveRecord::Base
backup_download: 45, backup_download: 45,
backup_destroy: 46, backup_destroy: 46,
notified_about_get_a_room: 47, notified_about_get_a_room: 47,
change_name: 48) change_name: 48,
post_locked: 49,
post_unlocked: 50)
end end
# Staff actions is a subset of all actions, used to audit actions taken by staff users. # Staff actions is a subset of all actions, used to audit actions taken by staff users.
@ -104,7 +106,9 @@ class UserHistory < ActiveRecord::Base
:activate_user, :activate_user,
:change_readonly_mode, :change_readonly_mode,
:backup_download, :backup_download,
:backup_destroy] :backup_destroy,
:post_locked,
:post_unlocked]
end end
def self.staff_action_ids def self.staff_action_ids

View File

@ -69,7 +69,8 @@ class PostSerializer < BasicPostSerializer
:is_auto_generated, :is_auto_generated,
:action_code, :action_code,
:action_code_who, :action_code_who,
:last_wiki_edit :last_wiki_edit,
:locked
def initialize(object, opts) def initialize(object, opts)
super(object, opts) super(object, opts)
@ -354,6 +355,15 @@ class PostSerializer < BasicPostSerializer
include_action_code? && action_code_who.present? include_action_code? && action_code_who.present?
end end
def locked
true
end
# Only show locked posts to the users who made the post and staff
def include_locked?
object.locked? && (yours || scope.is_staff?)
end
def last_wiki_edit def last_wiki_edit
object.revisions.last.updated_at object.revisions.last.updated_at
end end

View File

@ -95,6 +95,14 @@ class StaffActionLogger
target_user_id: user.id)) target_user_id: user.id))
end end
def log_post_lock(post, opts = {})
raise Discourse::InvalidParameters.new(:post) unless post && post.is_a?(Post)
UserHistory.create!(params(opts).merge(
action: UserHistory.actions[opts[:locked] ? :post_locked : :post_unlocked],
post_id: post.id)
)
end
def log_site_setting_change(setting_name, previous_value, new_value, opts = {}) def log_site_setting_change(setting_name, previous_value, new_value, opts = {})
raise Discourse::InvalidParameters.new(:setting_name) unless setting_name.present? && SiteSetting.respond_to?(setting_name) raise Discourse::InvalidParameters.new(:setting_name) unless setting_name.present? && SiteSetting.respond_to?(setting_name)
UserHistory.create(params(opts).merge(action: UserHistory.actions[:change_site_setting], UserHistory.create(params(opts).merge(action: UserHistory.actions[:change_site_setting],

View File

@ -1900,6 +1900,7 @@ en:
other: "(post withdrawn by author, will be automatically deleted in %{count} hours unless flagged)" other: "(post withdrawn by author, will be automatically deleted in %{count} hours unless flagged)"
collapse: "collapse" collapse: "collapse"
expand_collapse: "expand/collapse" expand_collapse: "expand/collapse"
locked: "a staff member has locked this post from being edited"
gap: gap:
one: "view 1 hidden reply" one: "view 1 hidden reply"
other: "view {{count}} hidden replies" other: "view {{count}} hidden replies"
@ -1981,6 +1982,10 @@ en:
unhide: "Unhide" unhide: "Unhide"
change_owner: "Change Ownership" change_owner: "Change Ownership"
grant_badge: "Grant Badge" grant_badge: "Grant Badge"
lock_post: "Lock Post"
lock_post_description: "prevent the poster from editing this post"
unlock_post: "Unlock Post"
unlock_post_description: "allow the poster to edit this post"
actions: actions:
flag: 'Flag' flag: 'Flag'
@ -3220,6 +3225,8 @@ en:
backup_destroy: "destroy backup" backup_destroy: "destroy backup"
reviewed_post: "reviewed post" reviewed_post: "reviewed post"
custom_staff: "plugin custom action" custom_staff: "plugin custom action"
post_locked: "post locked"
post_unlocked: "post unlocked"
screened_emails: screened_emails:
title: "Screened Emails" title: "Screened Emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed." description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."

View File

@ -475,6 +475,7 @@ Discourse::Application.routes.draw do
put "post_type" put "post_type"
put "rebake" put "rebake"
put "unhide" put "unhide"
put "locked"
get "replies" get "replies"
get "revisions/latest" => "posts#latest_revision" get "revisions/latest" => "posts#latest_revision"
get "revisions/:revision" => "posts#revisions", constraints: { revision: /\d+/ } get "revisions/:revision" => "posts#revisions", constraints: { revision: /\d+/ }

View File

@ -0,0 +1,5 @@
class AddLockedByToPosts < ActiveRecord::Migration[5.1]
def change
add_column :posts, :locked_by_id, :integer
end
end

View File

@ -46,6 +46,10 @@ module PostGuardian
!!result !!result
end end
def can_lock_post?(post)
can_see_post?(post) && is_staff?
end
def can_defer_flags?(post) def can_defer_flags?(post)
can_see_post?(post) && is_staff? && post can_see_post?(post) && is_staff? && post
end end
@ -98,6 +102,9 @@ module PostGuardian
return true if is_admin? return true if is_admin?
# Must be staff to edit a locked post
return false if post.locked? && !is_staff?
if is_staff? || @user.has_trust_level?(TrustLevel[4]) if is_staff? || @user.has_trust_level?(TrustLevel[4])
return can_create_post?(post.topic) return can_create_post?(post.topic)
end end
@ -106,6 +113,7 @@ module PostGuardian
return false return false
end end
if post.wiki && (@user.trust_level >= SiteSetting.min_trust_to_edit_wiki_post.to_i) if post.wiki && (@user.trust_level >= SiteSetting.min_trust_to_edit_wiki_post.to_i)
return can_create_post?(post.topic) return can_create_post?(post.topic)
end end
@ -114,6 +122,7 @@ module PostGuardian
return false return false
end end
if is_my_own?(post) if is_my_own?(post)
if post.hidden? if post.hidden?
return false if post.hidden_at.present? && return false if post.hidden_at.present? &&

23
lib/post_locker.rb Normal file
View File

@ -0,0 +1,23 @@
class PostLocker
def initialize(post, user)
@post, @user = post, user
end
def lock
Guardian.new(@user).ensure_can_lock_post!(@post)
Post.transaction do
@post.update_column(:locked_by_id, @user.id)
StaffActionLogger.new(@user).log_post_lock(@post, locked: true)
end
end
def unlock
Guardian.new(@user).ensure_can_lock_post!(@post)
Post.transaction do
@post.update_column(:locked_by_id, nil)
StaffActionLogger.new(@user).log_post_lock(@post, locked: false)
end
end
end

View File

@ -2,6 +2,7 @@ require 'rails_helper';
require 'guardian' require 'guardian'
require_dependency 'post_destroyer' require_dependency 'post_destroyer'
require_dependency 'post_locker'
describe Guardian do describe Guardian do
@ -1091,6 +1092,11 @@ describe Guardian do
expect(Guardian.new(post.user).can_edit?(post)).to be_truthy expect(Guardian.new(post.user).can_edit?(post)).to be_truthy
end end
it 'returns false if you try to edit a locked post' do
post.locked_by_id = moderator.id
expect(Guardian.new(post.user).can_edit?(post)).to be_falsey
end
it "returns false if the post is hidden due to flagging and it's too soon" do it "returns false if the post is hidden due to flagging and it's too soon" do
post.hidden = true post.hidden = true
post.hidden_at = Time.now post.hidden_at = Time.now
@ -1164,6 +1170,11 @@ describe Guardian do
expect(Guardian.new(moderator).can_edit?(post)).to be_truthy expect(Guardian.new(moderator).can_edit?(post)).to be_truthy
end end
it 'returns true as a moderator, even if locked' do
post.locked_by_id = admin.id
expect(Guardian.new(moderator).can_edit?(post)).to be_truthy
end
it 'returns true as an admin' do it 'returns true as an admin' do
expect(Guardian.new(admin).can_edit?(post)).to be_truthy expect(Guardian.new(admin).can_edit?(post)).to be_truthy
end end

View File

@ -0,0 +1,49 @@
require 'rails_helper'
require_dependency 'post_locker'
describe PostLocker do
let(:moderator) { Fabricate(:moderator) }
let(:post) { Fabricate(:post) }
it "doesn't allow regular users to lock posts" do
expect {
PostLocker.new(post, post.user).lock
}.to raise_error(Discourse::InvalidAccess)
expect(post).not_to be_locked
expect(post.locked_by_id).to be_blank
end
it "doesn't allow regular users to unlock posts" do
PostLocker.new(post, moderator).lock
expect {
PostLocker.new(post, post.user).lock
}.to raise_error(Discourse::InvalidAccess)
expect(post).to be_locked
expect(post.locked_by_id).to eq(moderator.id)
end
it "allows staff to lock and unlock posts" do
expect(post).not_to be_locked
expect(post.locked_by_id).to be_blank
PostLocker.new(post, moderator).lock
expect(post).to be_locked
expect(post.locked_by_id).to eq(moderator.id)
expect(UserHistory.where(
acting_user_id: moderator.id,
action: UserHistory.actions[:post_locked]
).exists?).to eq(true)
PostLocker.new(post, moderator).unlock
expect(post).not_to be_locked
expect(post.locked_by_id).to be_blank
expect(UserHistory.where(
acting_user_id: moderator.id,
action: UserHistory.actions[:post_unlocked]
).exists?).to eq(true)
end
end

View File

@ -199,4 +199,23 @@ RSpec.describe PostsController do
end end
end end
end end
describe "#locked" do
before do
sign_in(Fabricate(:moderator))
end
it 'can lock and unlock the post' do
put "/posts/#{public_post.id}/locked.json", params: { locked: "true" }
expect(response).to be_success
public_post.reload
expect(public_post).to be_locked
put "/posts/#{public_post.id}/locked.json", params: { locked: "false" }
expect(response).to be_success
public_post.reload
expect(public_post).not_to be_locked
end
end
end end