From 89248580dce1be67161b25c9155759bef598d099 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Wed, 9 Mar 2016 21:10:49 +0530 Subject: [PATCH] FEATURE: revert post to a specific revision --- .../discourse/controllers/history.js.es6 | 26 +++++++ .../javascripts/discourse/models/post.js.es6 | 4 + .../javascripts/discourse/routes/topic.js.es6 | 1 + .../discourse/templates/modal/history.hbs | 16 ++-- app/controllers/posts_controller.rb | 49 +++++++++++++ config/locales/client.en.yml | 1 + config/locales/server.en.yml | 1 + config/routes.rb | 1 + spec/controllers/posts_controller_spec.rb | 73 +++++++++++++++++++ 9 files changed, 166 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/history.js.es6 b/app/assets/javascripts/discourse/controllers/history.js.es6 index f79970004..52d0d1de8 100644 --- a/app/assets/javascripts/discourse/controllers/history.js.es6 +++ b/app/assets/javascripts/discourse/controllers/history.js.es6 @@ -29,6 +29,25 @@ export default Ember.Controller.extend(ModalFunctionality, { Discourse.Post.showRevision(postId, postVersion).then(() => this.refresh(postId, postVersion)); }, + revert(post, postVersion) { + post.revertToRevision(postVersion).then((result) => { + this.refresh(post.get('id'), postVersion); + if (result.topic) { + post.set('topic.slug', result.topic.slug); + post.set('topic.title', result.topic.title); + post.set('topic.fancy_title', result.topic.fancy_title); + } + if (result.category_id) { + post.set('topic.category', Discourse.Category.findById(result.category_id)); + } + this.send("closeModal"); + }).catch(function(e) { + if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors && e.jqXHR.responseJSON.errors[0]) { + bootbox.alert(e.jqXHR.responseJSON.errors[0]); + } + }); + }, + @computed('model.created_at') createdAtDate(createdAt) { return moment(createdAt).format("LLLL"); @@ -69,6 +88,11 @@ export default Ember.Controller.extend(ModalFunctionality, { return !prevHidden && this.currentUser && this.currentUser.get('staff'); }, + @computed() + displayRevert() { + return this.currentUser && this.currentUser.get('staff'); + }, + isEitherRevisionHidden: Ember.computed.or("model.previous_hidden", "model.current_hidden"), @computed('model.previous_hidden', 'model.current_hidden', 'displayingInline') @@ -142,6 +166,8 @@ export default Ember.Controller.extend(ModalFunctionality, { hideVersion() { this.hide(this.get("model.post_id"), this.get("model.current_revision")); }, showVersion() { this.show(this.get("model.post_id"), this.get("model.current_revision")); }, + revertToVersion() { this.revert(this.get("post"), this.get("model.current_revision")); }, + displayInline() { this.set("viewMode", "inline"); }, displaySideBySide() { this.set("viewMode", "side_by_side"); }, displaySideBySideMarkdown() { this.set("viewMode", "side_by_side_markdown"); } diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6 index 8565cfc7d..37005bc99 100644 --- a/app/assets/javascripts/discourse/models/post.js.es6 +++ b/app/assets/javascripts/discourse/models/post.js.es6 @@ -271,6 +271,10 @@ const Post = RestModel.extend({ json = Post.munge(json); this.set('actions_summary', json.actions_summary); } + }, + + revertToRevision(version) { + return Discourse.ajax(`/posts/${this.get('id')}/revisions/${version}/revert`, { type: 'PUT' }); } }); diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6 index 6e17a41b5..4297186f0 100644 --- a/app/assets/javascripts/discourse/routes/topic.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic.js.es6 @@ -73,6 +73,7 @@ const TopicRoute = Discourse.Route.extend({ showHistory(model) { showModal('history', { model }); this.controllerFor('history').refresh(model.get("id"), "latest"); + this.controllerFor('history').set('post', model); this.controllerFor('modal').set('modalClass', 'history-modal'); }, diff --git a/app/assets/javascripts/discourse/templates/modal/history.hbs b/app/assets/javascripts/discourse/templates/modal/history.hbs index bcf20fbc1..21e166275 100644 --- a/app/assets/javascripts/discourse/templates/modal/history.hbs +++ b/app/assets/javascripts/discourse/templates/modal/history.hbs @@ -10,12 +10,6 @@ {{d-button action="loadNextVersion" icon="forward" title="post.revisions.controls.next" disabled=loadNextDisabled}} {{d-button action="loadLastVersion" icon="fast-forward" title="post.revisions.controls.last" disabled=loadLastDisabled}} - {{#if displayHide}} - {{d-button action="hideVersion" icon="trash-o" title="post.revisions.controls.hide" class="btn-danger" disabled=loading}} - {{/if}} - {{#if displayShow}} - {{d-button action="showVersion" icon="undo" title="post.revisions.controls.show" disabled=loading}} - {{/if}}
{{d-button action="displayInline" label="post.revisions.displays.inline.button" title="post.revisions.displays.inline.title" class=inlineClass}} @@ -85,5 +79,15 @@
{{{bodyDiff}}}
+ + {{#if displayRevert}} + {{d-button action="revertToVersion" icon="undo" label="post.revisions.controls.revert" class="btn-danger" disabled=loading}} + {{/if}} + {{#if displayHide}} + {{d-button action="hideVersion" icon="eye-slash" label="post.revisions.controls.hide" class="btn-danger" disabled=loading}} + {{/if}} + {{#if displayShow}} + {{d-button action="showVersion" icon="eye" label="post.revisions.controls.show" disabled=loading}} + {{/if}}
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index f9800e885..fdf3dbb68 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -282,6 +282,55 @@ class PostsController < ApplicationController render nothing: true end + def revert + raise Discourse::NotFound unless guardian.is_staff? + + post_id = params[:id] || params[:post_id] + revision = params[:revision].to_i + raise Discourse::InvalidParameters.new(:revision) if revision < 2 + + post_revision = PostRevision.find_by(post_id: post_id, number: revision) + raise Discourse::NotFound unless post_revision + + post = find_post_from_params + raise Discourse::NotFound if post.blank? + + post_revision.post = post + guardian.ensure_can_see!(post_revision) + guardian.ensure_can_edit!(post) + return render_json_error(I18n.t('revert_version_same')) if post_revision.modifications["raw"].blank? && post_revision.modifications["title"].blank? && post_revision.modifications["category_id"].blank? + + topic = Topic.with_deleted.find(post.topic_id) + + changes = {} + changes[:raw] = post_revision.modifications["raw"][0] if post_revision.modifications["raw"].present? && post_revision.modifications["raw"][0] != post.raw + if post.is_first_post? + changes[:title] = post_revision.modifications["title"][0] if post_revision.modifications["title"].present? && post_revision.modifications["title"][0] != topic.title + changes[:category_id] = post_revision.modifications["category_id"][0] if post_revision.modifications["category_id"].present? && post_revision.modifications["category_id"][0] != topic.category.id + end + return render_json_error(I18n.t('revert_version_same')) unless changes.length > 0 + changes[:edit_reason] = "reverted to version ##{post_revision.number.to_i - 1}" + + revisor = PostRevisor.new(post, topic) + revisor.revise!(current_user, changes) + + return render_json_error(post) if post.errors.present? + return render_json_error(topic) if topic.errors.present? + + post_serializer = PostSerializer.new(post, scope: guardian, root: false) + post_serializer.draft_sequence = DraftSequence.current(current_user, topic.draft_key) + link_counts = TopicLink.counts_for(guardian, topic, [post]) + post_serializer.single_post_link_counts = link_counts[post.id] if link_counts.present? + + result = { post: post_serializer.as_json } + if post.is_first_post? + result[:topic] = BasicTopicSerializer.new(topic, scope: guardian, root: false).as_json if post_revision.modifications["title"].present? + result[:category_id] = post_revision.modifications["category_id"][0] if post_revision.modifications["category_id"].present? + end + + render_json_dump(result) + end + def bookmark post = find_post_from_params diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 16834c894..110af9314 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1639,6 +1639,7 @@ en: last: "Last revision" hide: "Hide revision" show: "Show revision" + revert: "Revert to this revision" comparing_previous_to_current_out_of_total: "{{previous}} {{current}} / {{total}}" displays: inline: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index e76ba73b4..1497fd7fc 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -204,6 +204,7 @@ en: top: "Top topics" posts: "Latest posts" too_late_to_edit: "That post was created too long ago. It can no longer be edited or deleted." + revert_version_same: "The current version is same as the version you are trying to revert to." excerpt_image: "image" diff --git a/config/routes.rb b/config/routes.rb index d28299a7b..db70aabf1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -386,6 +386,7 @@ Discourse::Application.routes.draw do get "revisions/:revision" => "posts#revisions", constraints: { revision: /\d+/ } put "revisions/:revision/hide" => "posts#hide_revision", constraints: { revision: /\d+/ } put "revisions/:revision/show" => "posts#show_revision", constraints: { revision: /\d+/ } + put "revisions/:revision/revert" => "posts#revert", constraints: { revision: /\d+/ } put "recover" collection do delete "destroy_many" diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb index 14896e52d..67392c081 100644 --- a/spec/controllers/posts_controller_spec.rb +++ b/spec/controllers/posts_controller_spec.rb @@ -830,6 +830,79 @@ describe PostsController do end + describe 'revert post to a specific revision' do + include_examples 'action requires login', :put, :revert, post_id: 123, revision: 2 + + let(:post) { Fabricate(:post, user: logged_in_as, raw: "Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex") } + let(:post_revision) { Fabricate(:post_revision, post: post, modifications: {"raw" => ["this is original post body.", "this is edited post body."]}) } + let(:blank_post_revision) { Fabricate(:post_revision, post: post, modifications: {"edit_reason" => ["edit reason #1", "edit reason #2"]}) } + let(:same_post_revision) { Fabricate(:post_revision, post: post, modifications: {"raw" => ["Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex", "this is edited post body."]}) } + + let(:revert_params) do + { + post_id: post.id, + revision: post_revision.number + } + end + let(:moderator) { Fabricate(:moderator) } + + describe 'when logged in as a regular user' do + let(:logged_in_as) { log_in } + + it "does not work" do + xhr :put, :revert, revert_params + expect(response).to_not be_success + end + end + + describe "when logged in as staff" do + let(:logged_in_as) { log_in(:moderator) } + + it "throws an exception when revision is < 2" do + expect { + xhr :put, :revert, post_id: post.id, revision: 1 + }.to raise_error(Discourse::InvalidParameters) + end + + it "fails when post_revision record is not found" do + xhr :put, :revert, post_id: post.id, revision: post_revision.number + 1 + expect(response).to_not be_success + end + + it "fails when post record is not found" do + xhr :put, :revert, post_id: post.id + 1, revision: post_revision.number + expect(response).to_not be_success + end + + it "fails when revision is blank" do + xhr :put, :revert, post_id: post.id, revision: blank_post_revision.number + + expect(response.status).to eq(422) + expect(JSON.parse(response.body)['errors']).to include(I18n.t('revert_version_same')) + end + + it "fails when revised version is same as current version" do + xhr :put, :revert, post_id: post.id, revision: same_post_revision.number + + expect(response.status).to eq(422) + expect(JSON.parse(response.body)['errors']).to include(I18n.t('revert_version_same')) + end + + it "works!" do + xhr :put, :revert, revert_params + expect(response).to be_success + end + + it "supports reverting posts in deleted topics" do + first_post = post.topic.ordered_posts.first + PostDestroyer.new(moderator, first_post).destroy + + xhr :put, :revert, revert_params + expect(response).to be_success + end + end + end + describe 'expandable embedded posts' do let(:post) { Fabricate(:post) }