diff --git a/app/assets/javascripts/discourse/controllers/change-timestamp.js.es6 b/app/assets/javascripts/discourse/controllers/change-timestamp.js.es6 new file mode 100644 index 000000000..1d55e74cf --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/change-timestamp.js.es6 @@ -0,0 +1,58 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import computed from 'ember-addons/ember-computed-decorators'; + +// Modal related to changing the timestamp of posts +export default Ember.Controller.extend(ModalFunctionality, { + needs: ['topic'], + + topicController: Em.computed.alias('controllers.topic'), + saving: false, + date: '', + time: '', + + @computed('saving') + buttonTitle(saving) { + return saving ? I18n.t('saving') : I18n.t('topic.change_timestamp.action'); + }, + + @computed('date', 'time') + createdAt(date, time) { + return moment(date + ' ' + time, 'YYYY-MM-DD HH:mm:ss'); + }, + + @computed('createdAt') + validTimestamp(createdAt) { + return moment().diff(createdAt, 'minutes') < 0; + }, + + @computed('saving', 'date', 'validTimestamp') + buttonDisabled() { + if (this.get('saving') || this.get('validTimestamp')) return true; + return Ember.isEmpty(this.get('date')); + }, + + onShow: function() { + this.setProperties({ + date: moment().format('YYYY-MM-DD') + }); + }, + + actions: { + changeTimestamp: function() { + this.set('saving', true); + const self = this; + + Discourse.Topic.changeTimestamp( + this.get('topicController.model.id'), + this.get('createdAt').unix() + ).then(function() { + self.send('closeModal'); + self.setProperties({ date: '', time: '', saving: false }); + }).catch(function() { + self.flash(I18n.t('topic.change_timestamp.error'), 'alert-error'); + self.set('saving', false); + }); + return false; + } + } +}); diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 073784934..88d99fa3b 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -480,6 +480,17 @@ Topic.reopenClass({ return promise; }, + changeTimestamp(topicId, timestamp) { + const promise = Discourse.ajax("/t/" + topicId + '/change-timestamp', { + type: 'PUT', + data: { timestamp: timestamp }, + }).then(function(result) { + if (result.success) return result; + promise.reject(new Error("error updating timestamp of topic")); + }); + return promise; + }, + bulkOperation(topics, operation) { return Discourse.ajax("/topics/bulk", { type: 'PUT', diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6 index c79ccbe5c..04d3ae202 100644 --- a/app/assets/javascripts/discourse/routes/topic.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic.js.es6 @@ -60,6 +60,10 @@ const TopicRoute = Discourse.Route.extend({ this.controllerFor('modal').set('modalClass', 'edit-auto-close-modal'); }, + showChangeTimestamp() { + showModal('change-timestamp', { model: this.modelFor('topic'), title: 'topic.change_timestamp.title' }); + }, + showFeatureTopic() { showModal('featureTopic', { model: this.modelFor('topic'), title: 'topic.feature_topic.title' }); this.controllerFor('modal').set('modalClass', 'feature-topic-modal'); diff --git a/app/assets/javascripts/discourse/templates/modal/change-timestamp.hbs b/app/assets/javascripts/discourse/templates/modal/change-timestamp.hbs new file mode 100644 index 000000000..c76c58e12 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/change-timestamp.hbs @@ -0,0 +1,18 @@ + + + diff --git a/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs b/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs index 004df5255..2f190df21 100644 --- a/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs +++ b/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs @@ -38,6 +38,10 @@ {{/if}} {{/unless}} +
  • + {{d-button action="showChangeTimestamp" icon="calendar" label="topic.change_timestamp.title" class="btn-admin"}} +
  • +
  • {{#if model.archived}} {{d-button action="toggleArchived" icon="folder" label="topic.actions.unarchive" class="btn-admin"}} diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 01d960e54..f728f35c6 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -24,6 +24,7 @@ class TopicsController < ApplicationController :bulk, :reset_new, :change_post_owners, + :change_timestamps, :bookmark, :unsubscribe] @@ -375,6 +376,22 @@ class TopicsController < ApplicationController end end + def change_timestamps + params.require(:topic_id) + params.require(:timestamp) + + guardian.ensure_can_change_post_owner! + + begin + PostTimestampChanger.new( topic_id: params[:topic_id].to_i, + timestamp: params[:timestamp].to_i ).change! + + render json: success_json + rescue ActiveRecord::RecordInvalid + render json: failed_json, status: 422 + end + end + def clear_pin topic = Topic.find_by(id: params[:topic_id].to_i) guardian.ensure_can_see!(topic) diff --git a/app/services/post_timestamp_changer.rb b/app/services/post_timestamp_changer.rb new file mode 100644 index 000000000..65b056128 --- /dev/null +++ b/app/services/post_timestamp_changer.rb @@ -0,0 +1,43 @@ +class PostTimestampChanger + def initialize(params) + @topic = Topic.with_deleted.find(params[:topic_id]) + @posts = @topic.posts + @timestamp = Time.at(params[:timestamp]) + @time_difference = calculate_time_difference + end + + def change! + ActiveRecord::Base.transaction do + update_topic + + @posts.each do |post| + if post.is_first_post? + update_post(post, @timestamp) + else + update_post(post, Time.at(post.created_at.to_f + @time_difference)) + end + end + end + + # Burst the cache for stats + [AdminDashboardData, About].each { |klass| $redis.del klass.stats_cache_key } + end + + private + + def calculate_time_difference + @timestamp - @topic.created_at + end + + def update_topic + @topic.update_attributes( + created_at: @timestamp, + updated_at: @timestamp, + bumped_at: @timestamp + ) + end + + def update_post(post, timestamp) + post.update_attributes(created_at: timestamp, updated_at: timestamp) + end +end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 520b881c7..d85e2a46d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1241,6 +1241,13 @@ en: other: "Please choose the new owner of the {{count}} posts by {{old_user}}." instructions_warn: "Note that any notifications about this post will not be transferred to the new user retroactively.
    Warning: Currently, no post-dependent data is transferred over to the new user. Use with caution." + change_timestamp: + title: "Change Timestamp" + action: "change timestamp" + invalid_timestamp: "Timestamp cannot be in the future." + error: "There was an error changing the timestamp of the topic." + instructions: "Please select the new timestamp of the topic. Posts in the topic will be updated to have the same time difference." + multi_select: select: 'select' selected: 'selected ({{count}})' diff --git a/config/routes.rb b/config/routes.rb index 9b0e6cc40..235f82432 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -469,6 +469,7 @@ Discourse::Application.routes.draw do post "t/:topic_id/move-posts" => "topics#move_posts", constraints: {topic_id: /\d+/} post "t/:topic_id/merge-topic" => "topics#merge_topic", constraints: {topic_id: /\d+/} post "t/:topic_id/change-owner" => "topics#change_post_owners", constraints: {topic_id: /\d+/} + put "t/:topic_id/change-timestamp" => "topics#change_timestamps", constraints: {topic_id: /\d+/} delete "t/:topic_id/timings" => "topics#destroy_timings", constraints: {topic_id: /\d+/} put "t/:topic_id/bookmark" => "topics#bookmark", constraints: {topic_id: /\d+/} put "t/:topic_id/remove_bookmarks" => "topics#remove_bookmarks", constraints: {topic_id: /\d+/} diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb index 8994e5de9..8b31305ee 100644 --- a/spec/controllers/topics_controller_spec.rb +++ b/spec/controllers/topics_controller_spec.rb @@ -263,6 +263,45 @@ describe TopicsController do end end + context 'change_timestamps' do + let(:params) { { topic_id: 1, timestamp: Time.zone.now } } + + it 'needs you to be logged in' do + expect { xhr :put, :change_timestamps, params }.to raise_error(Discourse::NotLoggedIn) + end + + [:moderator, :trust_level_4].each do |user| + describe "forbidden to #{user}" do + let!(user) { log_in(user) } + + it 'correctly denies' do + xhr :put, :change_timestamps, params + expect(response).to be_forbidden + end + end + end + + describe 'changing timestamps' do + let!(:admin) { log_in(:admin) } + let(:old_timestamp) { Time.zone.now } + let(:new_timestamp) { old_timestamp - 1.day } + let!(:topic) { Fabricate(:topic, created_at: old_timestamp) } + let!(:p1) { Fabricate(:post, topic_id: topic.id, created_at: old_timestamp) } + let!(:p2) { Fabricate(:post, topic_id: topic.id, created_at: old_timestamp + 1.day) } + + it 'raises an error with a missing parameter' do + expect { xhr :put, :change_timestamps, topic_id: 1 }.to raise_error(ActionController::ParameterMissing) + end + + it 'should update the timestamps of selected posts' do + xhr :put, :change_timestamps, topic_id: topic.id, timestamp: new_timestamp.to_f + expect(topic.reload.created_at.to_s).to eq(new_timestamp.to_s) + expect(p1.reload.created_at.to_s).to eq(new_timestamp.to_s) + expect(p2.reload.created_at.to_s).to eq(old_timestamp.to_s) + end + end + end + context 'clear_pin' do it 'needs you to be logged in' do expect { xhr :put, :clear_pin, topic_id: 1 }.to raise_error(Discourse::NotLoggedIn) diff --git a/spec/services/post_timestamp_changer_spec.rb b/spec/services/post_timestamp_changer_spec.rb new file mode 100644 index 000000000..506459687 --- /dev/null +++ b/spec/services/post_timestamp_changer_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe PostTimestampChanger do + describe "change!" do + let(:old_timestamp) { Time.zone.now } + let(:new_timestamp) { old_timestamp + 1.day } + let!(:topic) { Fabricate(:topic, created_at: old_timestamp) } + let!(:p1) { Fabricate(:post, topic: topic, created_at: old_timestamp) } + let!(:p2) { Fabricate(:post, topic: topic, created_at: old_timestamp + 1.day) } + let(:params) { { topic_id: topic.id, timestamp: new_timestamp.to_f } } + + it 'changes the timestamp of the topic and opening post' do + PostTimestampChanger.new(params).change! + + topic.reload + [:created_at, :updated_at, :bumped_at].each do |column| + expect(topic.public_send(column).to_s).to eq(new_timestamp.to_s) + end + + p1.reload + [:created_at, :updated_at].each do |column| + expect(p1.public_send(column).to_s).to eq(new_timestamp.to_s) + end + end + + describe 'predated timestamp' do + it 'updates the timestamp of posts in the topic with the time difference applied' do + PostTimestampChanger.new(params).change! + + p2.reload + [:created_at, :updated_at].each do |column| + expect(p2.public_send(column).to_s).to eq((old_timestamp + 2.day).to_s) + end + end + end + + describe 'backdated timestamp' do + let(:new_timestamp) { old_timestamp - 1.day } + + it 'updates the timestamp of posts in the topic with the time difference applied' do + PostTimestampChanger.new(params).change! + + p2.reload + [:created_at, :updated_at].each do |column| + expect(p2.public_send(column).to_s).to eq((old_timestamp).to_s) + end + end + end + + it 'deletes the stats cache' do + $redis.expects(:del).twice + PostTimestampChanger.new(params).change! + end + end +end