FEATURE: Allow admins to schedule a topic to be published in the future.

This commit is contained in:
Guo Xiang Tan 2017-04-03 17:28:41 +08:00
parent dc5a6e7cda
commit f4758a4c4d
23 changed files with 359 additions and 177 deletions

View File

@ -1,9 +1,12 @@
import computed from "ember-addons/ember-computed-decorators"; import { default as computed, observes } from "ember-addons/ember-computed-decorators";
import { observes } from "ember-addons/ember-computed-decorators";
export default Ember.Component.extend({ export default Ember.Component.extend({
limited: false, limited: false,
inputValid: false,
didInsertElement() {
this._super();
this._updateInputValid();
},
@computed("limited") @computed("limited")
inputUnitsKey(limited) { inputUnitsKey(limited) {

View File

@ -3,6 +3,7 @@ import { categoryBadgeHTML } from 'discourse/helpers/category-link';
import computed from 'ember-addons/ember-computed-decorators'; import computed from 'ember-addons/ember-computed-decorators';
import { observes, on } from 'ember-addons/ember-computed-decorators'; import { observes, on } from 'ember-addons/ember-computed-decorators';
import PermissionType from 'discourse/models/permission-type'; import PermissionType from 'discourse/models/permission-type';
import Category from 'discourse/models/category';
export default ComboboxView.extend({ export default ComboboxView.extend({
classNames: ['combobox category-combobox'], classNames: ['combobox category-combobox'],
@ -14,13 +15,16 @@ export default ComboboxView.extend({
content(scopedCategoryId, categories) { content(scopedCategoryId, categories) {
// Always scope to the parent of a category, if present // Always scope to the parent of a category, if present
if (scopedCategoryId) { if (scopedCategoryId) {
const scopedCat = Discourse.Category.findById(scopedCategoryId); const scopedCat = Category.findById(scopedCategoryId);
scopedCategoryId = scopedCat.get('parent_category_id') || scopedCat.get('id'); scopedCategoryId = scopedCat.get('parent_category_id') || scopedCat.get('id');
} }
const excludeCategoryId = this.get('excludeCategoryId');
return categories.filter(c => { return categories.filter(c => {
if (scopedCategoryId && c.get('id') !== scopedCategoryId && c.get('parent_category_id') !== scopedCategoryId) { return false; } const categoryId = c.get('id');
if (c.get('isUncategorizedCategory')) { return false; } if (scopedCategoryId && categoryId !== scopedCategoryId && c.get('parent_category_id') !== scopedCategoryId) { return false; }
if (c.get('isUncategorizedCategory') || excludeCategoryId === categoryId) { return false; }
return c.get('permission') === PermissionType.FULL; return c.get('permission') === PermissionType.FULL;
}); });
}, },
@ -30,8 +34,8 @@ export default ComboboxView.extend({
_updateCategories() { _updateCategories() {
if (!this.get('categories')) { if (!this.get('categories')) {
const categories = Discourse.SiteSettings.fixed_category_positions_on_create ? const categories = Discourse.SiteSettings.fixed_category_positions_on_create ?
Discourse.Category.list() : Category.list() :
Discourse.Category.listByActivity(); Category.listByActivity();
this.set('categories', categories); this.set('categories', categories);
} }
}, },
@ -42,7 +46,7 @@ export default ComboboxView.extend({
if (rootNone) { if (rootNone) {
return "category.none"; return "category.none";
} else { } else {
return Discourse.Category.findUncategorized(); return Category.findUncategorized();
} }
} else { } else {
return 'category.choose'; return 'category.choose';
@ -54,12 +58,12 @@ export default ComboboxView.extend({
// If we have no id, but text with the uncategorized name, we can use that badge. // If we have no id, but text with the uncategorized name, we can use that badge.
if (Ember.isEmpty(item.id)) { if (Ember.isEmpty(item.id)) {
const uncat = Discourse.Category.findUncategorized(); const uncat = Category.findUncategorized();
if (uncat && uncat.get('name') === item.text) { if (uncat && uncat.get('name') === item.text) {
category = uncat; category = uncat;
} }
} else { } else {
category = Discourse.Category.findById(parseInt(item.id,10)); category = Category.findById(parseInt(item.id,10));
} }
if (!category) return item.text; if (!category) return item.text;
@ -67,7 +71,7 @@ export default ComboboxView.extend({
const parentCategoryId = category.get('parent_category_id'); const parentCategoryId = category.get('parent_category_id');
if (parentCategoryId) { if (parentCategoryId) {
result = categoryBadgeHTML(Discourse.Category.findById(parentCategoryId), {link: false}) + " " + result; result = categoryBadgeHTML(Category.findById(parentCategoryId), {link: false}) + " " + result;
} }
result += ` <span class='topic-count'>&times; ${category.get('topic_count')}</span>`; result += ` <span class='topic-count'>&times; ${category.get('topic_count')}</span>`;

View File

@ -1,4 +1,5 @@
import { bufferedRender } from 'discourse-common/lib/buffered-render'; import { bufferedRender } from 'discourse-common/lib/buffered-render';
import Category from 'discourse/models/category';
export default Ember.Component.extend(bufferedRender({ export default Ember.Component.extend(bufferedRender({
elementId: 'topic-status-info', elementId: 'topic-status-info',
@ -8,7 +9,8 @@ export default Ember.Component.extend(bufferedRender({
'topic.topic_status_update', 'topic.topic_status_update',
'topic.topic_status_update.execute_at', 'topic.topic_status_update.execute_at',
'topic.topic_status_update.based_on_last_post', 'topic.topic_status_update.based_on_last_post',
'topic.topic_status_update.duration' 'topic.topic_status_update.duration',
'topic.topic_status_update.category_id',
], ],
buildBuffer(buffer) { buildBuffer(buffer) {
@ -35,11 +37,23 @@ export default Ember.Component.extend(bufferedRender({
buffer.push('<h3><i class="fa fa-clock-o"></i> '); buffer.push('<h3><i class="fa fa-clock-o"></i> ');
buffer.push(I18n.t(this._noticeKey(), { let options = {
timeLeft: duration.humanize(true), timeLeft: duration.humanize(true),
duration: moment.duration(autoCloseHours, "hours").humanize() duration: moment.duration(autoCloseHours, "hours").humanize(),
})); };
const categoryId = this.get('topic.topic_status_update.category_id');
if (categoryId) {
const category = Category.findById(categoryId);
options = _.assign({
categoryName: category.get('slug'),
categoryUrl: category.get('url')
}, options);
}
buffer.push(I18n.t(this._noticeKey(), options));
buffer.push('</h3>'); buffer.push('</h3>');
// TODO Sam: concerned this can cause a heavy rerender loop // TODO Sam: concerned this can cause a heavy rerender loop

View File

@ -5,18 +5,36 @@ import { popupAjaxError } from 'discourse/lib/ajax-error';
const CLOSE_STATUS_TYPE = 'close'; const CLOSE_STATUS_TYPE = 'close';
const OPEN_STATUS_TYPE = 'open'; const OPEN_STATUS_TYPE = 'open';
const PUBLISH_TO_CATEGORY_STATUS_TYPE = 'publish_to_category';
export default Ember.Controller.extend(ModalFunctionality, { export default Ember.Controller.extend(ModalFunctionality, {
closeStatusType: CLOSE_STATUS_TYPE,
openStatusType: OPEN_STATUS_TYPE,
publishToCategoryStatusType: PUBLISH_TO_CATEGORY_STATUS_TYPE,
updateTimeValid: null, updateTimeValid: null,
updateTimeInvalid: Em.computed.not('updateTimeValid'), updateTimeInvalid: Em.computed.not('updateTimeValid'),
loading: false, loading: false,
updateTime: null, updateTime: null,
topicStatusUpdate: Ember.computed.alias("model.topic_status_update"), topicStatusUpdate: Ember.computed.alias("model.topic_status_update"),
selection: Ember.computed.alias('model.topic_status_update.status_type'), selection: Ember.computed.alias('model.topic_status_update.status_type'),
autoReopen: Ember.computed.equal('selection', OPEN_STATUS_TYPE), autoOpen: Ember.computed.equal('selection', OPEN_STATUS_TYPE),
autoClose: Ember.computed.equal('selection', CLOSE_STATUS_TYPE), autoClose: Ember.computed.equal('selection', CLOSE_STATUS_TYPE),
disableAutoReopen: Ember.computed.and('autoClose', 'updateTime'), publishToCategory: Ember.computed.equal('selection', PUBLISH_TO_CATEGORY_STATUS_TYPE),
disableAutoClose: Ember.computed.and('autoReopen', 'updateTime'),
@computed('autoClose', 'updateTime')
disableAutoClose(autoClose, updateTime) {
return updateTime && !autoClose;
},
@computed('autoOpen', 'updateTime')
disableAutoOpen(autoOpen, updateTime) {
return updateTime && !autoOpen;
},
@computed('publishToCatgory', 'updateTime')
disablePublishToCategory(publishToCatgory, updateTime) {
return updateTime && !publishToCatgory;
},
@computed('topicStatusUpdate.based_on_last_post', 'updateTime', 'model.last_posted_at') @computed('topicStatusUpdate.based_on_last_post', 'updateTime', 'model.last_posted_at')
willCloseImmediately(basedOnLastPost, updateTime, lastPostedAt) { willCloseImmediately(basedOnLastPost, updateTime, lastPostedAt) {
@ -42,7 +60,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
}, },
@observes("topicStatusUpdate.execute_at", "topicStatusUpdate.duration") @observes("topicStatusUpdate.execute_at", "topicStatusUpdate.duration")
setAutoCloseTime() { _setUpdateTime() {
let time = null; let time = null;
if (this.get("topicStatusUpdate.based_on_last_post")) { if (this.get("topicStatusUpdate.based_on_last_post")) {
@ -65,12 +83,18 @@ export default Ember.Controller.extend(ModalFunctionality, {
this.get('model.id'), this.get('model.id'),
time, time,
this.get('topicStatusUpdate.based_on_last_post'), this.get('topicStatusUpdate.based_on_last_post'),
status_type status_type,
this.get('categoryId')
).then(result => { ).then(result => {
if (time) { if (time) {
this.send('closeModal'); this.send('closeModal');
this.set('topicStatusUpdate.execute_at', result.execute_at);
this.set('topicStatusUpdate.duration', result.duration); this.get("topicStatusUpdate").setProperties({
execute_at: result.execute_at,
duration: result.duration,
category_id: result.category_id
});
this.set('model.closed', result.closed); this.set('model.closed', result.closed);
} else { } else {
this.set('topicStatusUpdate', Ember.Object.create({})); this.set('topicStatusUpdate', Ember.Object.create({}));

View File

@ -4,7 +4,7 @@ import RestModel from 'discourse/models/rest';
const TopicStatusUpdate = RestModel.extend({}); const TopicStatusUpdate = RestModel.extend({});
TopicStatusUpdate.reopenClass({ TopicStatusUpdate.reopenClass({
updateStatus(topicId, time, basedOnLastPost, statusType) { updateStatus(topicId, time, basedOnLastPost, statusType, categoryId) {
let data = { let data = {
time: time, time: time,
timezone_offset: (new Date().getTimezoneOffset()), timezone_offset: (new Date().getTimezoneOffset()),
@ -12,6 +12,7 @@ TopicStatusUpdate.reopenClass({
}; };
if (basedOnLastPost) data.based_on_last_post = basedOnLastPost; if (basedOnLastPost) data.based_on_last_post = basedOnLastPost;
if (categoryId) data.category_id = categoryId;
return ajax({ return ajax({
url: `/t/${topicId}/status_update`, url: `/t/${topicId}/status_update`,

View File

@ -52,7 +52,7 @@ const TopicRoute = Discourse.Route.extend({
showTopicStatusUpdate() { showTopicStatusUpdate() {
const model = this.modelFor('topic'); const model = this.modelFor('topic');
if (!model.get('topic_status_update')) model.set('topic_status_update', Ember.Object.create()); model.set('topic_status_update', Ember.Object.create(model.get('topic_status_update')));
showModal('edit-topic-status-update', { model }); showModal('edit-topic-status-update', { model });
this.controllerFor('modal').set('modalClass', 'topic-close-modal'); this.controllerFor('modal').set('modalClass', 'topic-close-modal');
}, },

View File

@ -1,21 +1,20 @@
<div class="auto-update-input"> <div class="auto-update-input">
<div> <div class="control-group">
<label> <label>
{{i18n inputLabelKey}} {{i18n inputLabelKey}}
{{text-field value=input}} {{text-field value=input}}
{{i18n inputUnitsKey}} {{i18n inputUnitsKey}}
</label> </label>
{{#if inputExamplesKey}}
<div class="examples">
{{i18n inputExamplesKey}}
</div>
{{/if}}
</div> </div>
{{#if inputExamplesKey}}
<div class="examples">
{{i18n inputExamplesKey}}
</div>
{{/if}}
{{#unless hideBasedOnLastPost}} {{#unless hideBasedOnLastPost}}
<div> <div class="control-group">
<label> <label>
{{input type="checkbox" checked=basedOnLastPost}} {{input type="checkbox" checked=basedOnLastPost}}
{{i18n 'topic.auto_close.based_on_last_post'}} {{i18n 'topic.auto_close.based_on_last_post'}}

View File

@ -5,7 +5,7 @@
disabled=disableAutoClose disabled=disableAutoClose
name="auto-close" name="auto-close"
id="auto-close" id="auto-close"
value="close" value=closeStatusType
selection=selection}} selection=selection}}
<label class="radio" for="auto-close"> <label class="radio" for="auto-close">
@ -19,46 +19,70 @@
</label> </label>
{{radio-button {{radio-button
disabled=disableAutoReopen disabled=disableAutoOpen
name="auto-reopen" name="auto-reopen"
id="auto-reopen" id="auto-reopen"
value="open" value=openStatusType
selection=selection}} selection=selection}}
<label class="radio" for="auto-reopen"> <label class="radio" for="auto-reopen">
{{fa-icon "clock-o"}} {{fa-icon "unlock"}} {{fa-icon "clock-o"}} {{fa-icon "unlock"}}
{{#if model.closed}} {{#if model.closed}}
{{i18n 'topic.auto_reopen.title'}} {{i18n 'topic.auto_reopen.title'}}
{{else}} {{else}}
{{i18n 'topic.temp_close.title'}} {{i18n 'topic.temp_close.title'}}
{{/if}} {{/if}}
</label> </label>
{{radio-button
disabled=disablePublishToCategory
name="publish-to-category"
id="publish-to-category"
value=publishToCategoryStatusType
selection=selection}}
<label class="radio" for="publish-to-category">
{{fa-icon "clock-o"}} {{i18n 'topic.publish_to_category.title'}}
</label>
</div> </div>
{{#if autoReopen}} <div>
{{auto-update-input {{#if autoOpen}}
inputLabelKey='topic.topic_status_update.time' {{auto-update-input
input=updateTime inputLabelKey='topic.topic_status_update.time'
inputValid=updateTimeValid input=updateTime
hideBasedOnLastPost=true inputValid=updateTimeValid
basedOnLastPost=false}} hideBasedOnLastPost=true
{{else if autoClose}} basedOnLastPost=false}}
{{auto-update-input {{else if publishToCategory}}
inputLabelKey='topic.topic_status_update.time' <div class="control-group">
input=updateTime <label>{{i18n 'topic.topic_status_update.publish_to'}}</label>
inputValid=updateTimeValid {{category-chooser valueAttribute="id" value=categoryId excludeCategoryId=model.category_id}}
limited=topicStatusUpdate.based_on_last_post
basedOnLastPost=topicStatusUpdate.based_on_last_post}}
{{#if willCloseImmediately}}
<div class="warning">
{{fa-icon "warning"}}
{{willCloseI18n}}
</div> </div>
{{auto-update-input
inputLabelKey='topic.topic_status_update.time'
input=updateTime
inputValid=updateTimeValid
hideBasedOnLastPost=true
basedOnLastPost=false}}
{{else if autoClose}}
{{auto-update-input
inputLabelKey='topic.topic_status_update.time'
input=updateTime
inputValid=updateTimeValid
limited=topicStatusUpdate.based_on_last_post
basedOnLastPost=topicStatusUpdate.based_on_last_post}}
{{#if willCloseImmediately}}
<div class="warning">
{{fa-icon "warning"}}
{{willCloseI18n}}
</div>
{{/if}}
{{/if}} {{/if}}
{{/if}} </div>
{{/d-modal-body}} {{/d-modal-body}}
<div class="modal-footer"> <div class="modal-footer">

View File

@ -134,12 +134,6 @@ div.ac-wrap {
} }
.auto-update-input { .auto-update-input {
div:not(:first-child) {
margin-top: 10px;
}
label {
font-size: 1em;
}
.examples { .examples {
color: lighten($primary, 40%); color: lighten($primary, 40%);
} }

View File

@ -295,38 +295,6 @@
} }
} }
.topic-close-modal {
.radios {
padding-bottom: 20px;
display: inline-block;
input[type='radio'] {
vertical-align: middle;
margin: 0px;
}
label {
padding: 0 10px 0px 5px;
display: inline-block;
}
}
.btn.pull-right {
margin-right: 10px;
}
form {
margin: 0;
}
.auto-update-input {
i.fa-clock-o {
font-size: 1.143em;
}
input {
margin: 0;
}
}
}
.edit-category-modal { .edit-category-modal {
.auto-update-input, .num-featured-topics-fields, .position-fields { .auto-update-input, .num-featured-topics-fields, .position-fields {
input[type=text] { input[type=text] {
@ -334,10 +302,6 @@
} }
} }
.auto-update-input label {
font-size: .929em;
}
.subcategory-list-style-field { .subcategory-list-style-field {
margin-left: 16px; margin-left: 16px;
} }

View File

@ -0,0 +1,29 @@
.topic-close-modal {
label {
display: inline-block;
}
.radios {
padding-bottom: 20px;
display: inline-block;
input[type='radio'] {
vertical-align: middle;
margin: 0px;
}
label {
padding: 0 10px 0px 5px;
}
}
.btn.pull-right {
margin-right: 10px;
}
.auto-update-input {
input {
margin: 0;
}
}
}

View File

@ -290,7 +290,7 @@ class TopicsController < ApplicationController
end end
def status_update def status_update
params.permit(:time, :timezone_offset, :based_on_last_post) params.permit(:time, :timezone_offset, :based_on_last_post, :category_id)
params.require(:status_type) params.require(:status_type)
status_type = status_type =
@ -303,12 +303,18 @@ class TopicsController < ApplicationController
topic = Topic.find_by(id: params[:topic_id]) topic = Topic.find_by(id: params[:topic_id])
guardian.ensure_can_moderate!(topic) guardian.ensure_can_moderate!(topic)
topic_status_update = topic.set_or_create_status_update( options = {
status_type,
params[:time],
by_user: current_user, by_user: current_user,
timezone_offset: params[:timezone_offset]&.to_i, timezone_offset: params[:timezone_offset]&.to_i,
based_on_last_post: params[:based_on_last_post] based_on_last_post: params[:based_on_last_post]
}
options.merge!(category_id: params[:category_id]) if !params[:category_id].blank?
topic_status_update = topic.set_or_create_status_update(
status_type,
params[:time],
options
) )
if topic.save if topic.save
@ -316,7 +322,8 @@ class TopicsController < ApplicationController
execute_at: topic_status_update&.execute_at, execute_at: topic_status_update&.execute_at,
duration: topic_status_update&.duration, duration: topic_status_update&.duration,
based_on_last_post: topic_status_update&.based_on_last_post, based_on_last_post: topic_status_update&.based_on_last_post,
closed: topic.closed closed: topic.closed,
category_id: topic_status_update&.category_id
}) })
else else
render_json_error(topic) render_json_error(topic)

View File

@ -0,0 +1,15 @@
module Jobs
class PublishTopicToCategory < Jobs::Base
def execute(args)
topic_status_update = TopicStatusUpdate.find_by(id: args[:topic_status_update_id])
raise Discourse::InvalidParameters.new(:topic_status_update_id) if topic_status_update.blank?
topic = topic_status_update.topic
return if topic.blank?
PostTimestampChanger.new(timestamp: Time.zone.now, topic: topic).change! do
topic.change_category_to_id(topic_status_update.category_id)
end
end
end
end

View File

@ -951,8 +951,10 @@ SQL
# * `nil` to delete the topic's status update. # * `nil` to delete the topic's status update.
# Options: # Options:
# * by_user: User who is setting the topic's status update. # * by_user: User who is setting the topic's status update.
# * timezone_offset: (Integer) offset from UTC in minutes of the given argument. Default 0. # * timezone_offset: (Integer) offset from UTC in minutes of the given argument.
def set_or_create_status_update(status_type, time, by_user: nil, timezone_offset: 0, based_on_last_post: false) # * based_on_last_post: True if time should be based on timestamp of the last post.
# * category_id: Category that the update will apply to.
def set_or_create_status_update(status_type, time, by_user: nil, timezone_offset: 0, based_on_last_post: false, category_id: SiteSetting.uncategorized_category_id)
topic_status_update = TopicStatusUpdate.find_or_initialize_by( topic_status_update = TopicStatusUpdate.find_or_initialize_by(
status_type: status_type, status_type: status_type,
topic: self topic: self
@ -966,6 +968,10 @@ SQL
time_now = Time.zone.now time_now = Time.zone.now
topic_status_update.based_on_last_post = !based_on_last_post.blank? topic_status_update.based_on_last_post = !based_on_last_post.blank?
if status_type == TopicStatusUpdate.types[:publish_to_category]
topic_status_update.category_id = category_id
end
if topic_status_update.based_on_last_post if topic_status_update.based_on_last_post
num_hours = time.to_f num_hours = time.to_f
@ -1191,51 +1197,56 @@ end
# #
# Table name: topics # Table name: topics
# #
# id :integer not null, primary key # id :integer not null, primary key
# title :string not null # title :string not null
# last_posted_at :datetime # last_posted_at :datetime
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# views :integer default(0), not null # views :integer default(0), not null
# posts_count :integer default(0), not null # posts_count :integer default(0), not null
# user_id :integer # user_id :integer
# last_post_user_id :integer not null # last_post_user_id :integer not null
# reply_count :integer default(0), not null # reply_count :integer default(0), not null
# featured_user1_id :integer # featured_user1_id :integer
# featured_user2_id :integer # featured_user2_id :integer
# featured_user3_id :integer # featured_user3_id :integer
# avg_time :integer # avg_time :integer
# deleted_at :datetime # deleted_at :datetime
# highest_post_number :integer default(0), not null # highest_post_number :integer default(0), not null
# image_url :string # image_url :string
# like_count :integer default(0), not null # like_count :integer default(0), not null
# incoming_link_count :integer default(0), not null # incoming_link_count :integer default(0), not null
# category_id :integer # category_id :integer
# visible :boolean default(TRUE), not null # visible :boolean default(TRUE), not null
# moderator_posts_count :integer default(0), not null # moderator_posts_count :integer default(0), not null
# closed :boolean default(FALSE), not null # closed :boolean default(FALSE), not null
# archived :boolean default(FALSE), not null # archived :boolean default(FALSE), not null
# bumped_at :datetime not null # bumped_at :datetime not null
# has_summary :boolean default(FALSE), not null # has_summary :boolean default(FALSE), not null
# vote_count :integer default(0), not null # vote_count :integer default(0), not null
# archetype :string default("regular"), not null # archetype :string default("regular"), not null
# featured_user4_id :integer # featured_user4_id :integer
# notify_moderators_count :integer default(0), not null # notify_moderators_count :integer default(0), not null
# spam_count :integer default(0), not null # spam_count :integer default(0), not null
# pinned_at :datetime # pinned_at :datetime
# score :float # score :float
# percent_rank :float default(1.0), not null # percent_rank :float default(1.0), not null
# subtype :string # subtype :string
# slug :string # slug :string
# deleted_by_id :integer # auto_close_at :datetime
# participant_count :integer default(1) # auto_close_user_id :integer
# word_count :integer # auto_close_started_at :datetime
# excerpt :string(1000) # deleted_by_id :integer
# pinned_globally :boolean default(FALSE), not null # participant_count :integer default(1)
# pinned_until :datetime # word_count :integer
# fancy_title :string(400) # excerpt :string(1000)
# highest_staff_post_number :integer default(0), not null # pinned_globally :boolean default(FALSE), not null
# featured_link :string # auto_close_based_on_last_post :boolean default(FALSE)
# auto_close_hours :float
# pinned_until :datetime
# fancy_title :string(400)
# highest_staff_post_number :integer default(0), not null
# featured_link :string
# #
# Indexes # Indexes
# #

View File

@ -8,9 +8,7 @@ class TopicStatusUpdate < ActiveRecord::Base
validates :topic_id, presence: true validates :topic_id, presence: true
validates :execute_at, presence: true validates :execute_at, presence: true
validates :status_type, presence: true validates :status_type, presence: true
validates :status_type, uniqueness: { scope: [:topic_id, :deleted_at] } validates :status_type, uniqueness: { scope: [:topic_id, :deleted_at] }
validate :ensure_update_will_happen validate :ensure_update_will_happen
before_save do before_save do
@ -22,7 +20,7 @@ class TopicStatusUpdate < ActiveRecord::Base
end end
after_save do after_save do
if execute_at_changed? || user_id_changed? if (execute_at_changed? || user_id_changed?) && topic
now = Time.zone.now now = Time.zone.now
time = execute_at < now ? now : execute_at time = execute_at < now ? now : execute_at
@ -33,7 +31,8 @@ class TopicStatusUpdate < ActiveRecord::Base
def self.types def self.types
@types ||= Enum.new( @types ||= Enum.new(
close: 1, close: 1,
open: 2 open: 2,
publish_to_category: 3
) )
end end
@ -69,26 +68,30 @@ class TopicStatusUpdate < ActiveRecord::Base
end end
alias_method :cancel_auto_open_job, :cancel_auto_close_job alias_method :cancel_auto_open_job, :cancel_auto_close_job
def schedule_auto_open_job(time) def cancel_auto_publish_to_category_job
if topic Jobs.cancel_scheduled_job(:publish_topic_to_category, topic_status_update_id: id)
topic.update_status('closed', true, user) if !topic.closed end
Jobs.enqueue_at(time, :toggle_topic_closed, def schedule_auto_open_job(time)
topic_status_update_id: id, topic.update_status('closed', true, user) if !topic.closed
state: false
) Jobs.enqueue_at(time, :toggle_topic_closed,
end topic_status_update_id: id,
state: false
)
end end
def schedule_auto_close_job(time) def schedule_auto_close_job(time)
if topic topic.update_status('closed', false, user) if topic.closed
topic.update_status('closed', false, user) if topic.closed
Jobs.enqueue_at(time, :toggle_topic_closed, Jobs.enqueue_at(time, :toggle_topic_closed,
topic_status_update_id: id, topic_status_update_id: id,
state: true state: true
) )
end end
def schedule_auto_publish_to_category_job(time)
Jobs.enqueue_at(time, :publish_topic_to_category, topic_status_update_id: id)
end end
end end
@ -106,6 +109,7 @@ end
# deleted_by_id :integer # deleted_by_id :integer
# created_at :datetime # created_at :datetime
# updated_at :datetime # updated_at :datetime
# category_id :integer
# #
# Indexes # Indexes
# #

View File

@ -3,7 +3,8 @@ class TopicStatusUpdateSerializer < ApplicationSerializer
:execute_at, :execute_at,
:duration, :duration,
:based_on_last_post, :based_on_last_post,
:status_type :status_type,
:category_id
def status_type def status_type
TopicStatusUpdate.types[object.status_type] TopicStatusUpdate.types[object.status_type]

View File

@ -1,6 +1,6 @@
class PostTimestampChanger class PostTimestampChanger
def initialize(params) def initialize(params)
@topic = Topic.with_deleted.find(params[:topic_id]) @topic = params[:topic] || Topic.with_deleted.find(params[:topic_id])
@posts = @topic.posts @posts = @topic.posts
@timestamp = Time.at(params[:timestamp]) @timestamp = Time.at(params[:timestamp])
@time_difference = calculate_time_difference @time_difference = calculate_time_difference
@ -21,6 +21,8 @@ class PostTimestampChanger
end end
update_topic(last_posted_at) update_topic(last_posted_at)
yield(@topic) if block_given?
end end
# Burst the cache for stats # Burst the cache for stats

View File

@ -1474,6 +1474,7 @@ en:
save: "Set Timer" save: "Set Timer"
time: "Time:" time: "Time:"
remove: "Remove Timer" remove: "Remove Timer"
publish_to: "Publish To:"
auto_update_input: auto_update_input:
limited: limited:
units: "(# of hours)" units: "(# of hours)"
@ -1481,6 +1482,8 @@ en:
all: all:
units: "" units: ""
examples: 'Enter number of hours (24), absolute time (17:30) or timestamp (2013-11-22 14:00).' examples: 'Enter number of hours (24), absolute time (17:30) or timestamp (2013-11-22 14:00).'
publish_to_category:
title: "Schedule Publishing"
temp_open: temp_open:
title: "Open Temporarily" title: "Open Temporarily"
auto_reopen: auto_reopen:
@ -1496,7 +1499,7 @@ en:
status_update_notice: status_update_notice:
auto_open: "This topic will automatically open %{timeLeft}." auto_open: "This topic will automatically open %{timeLeft}."
auto_close: "This topic will automatically close %{timeLeft}." auto_close: "This topic will automatically close %{timeLeft}."
auto_open_based_on_last_post: "This topic will open %{duration} after the last reply." auto_publish_to_category: "This topic will be published to <a href=%{categoryUrl}>#%{categoryName}</a> %{timeLeft}."
auto_close_based_on_last_post: "This topic will close %{duration} after the last reply." auto_close_based_on_last_post: "This topic will close %{duration} after the last reply."
auto_close_title: 'Auto-Close Settings' auto_close_title: 'Auto-Close Settings'
auto_close_immediate: auto_close_immediate:

View File

@ -0,0 +1,5 @@
class AddCategoryIdToTopicStatusUpdates < ActiveRecord::Migration
def change
add_column :topic_status_updates, :category_id, :integer
end
end

View File

@ -260,6 +260,9 @@ describe PostCreator do
end end
describe "topic's auto close" do describe "topic's auto close" do
before do
SiteSetting.queue_jobs = true
end
it "doesn't update topic's auto close when it's not based on last post" do it "doesn't update topic's auto close when it's not based on last post" do
Timecop.freeze do Timecop.freeze do
@ -275,8 +278,6 @@ describe PostCreator do
end end
it "updates topic's auto close date when it's based on last post" do it "updates topic's auto close date when it's based on last post" do
SiteSetting.queue_jobs = true
Timecop.freeze do Timecop.freeze do
topic = Fabricate(:topic, topic = Fabricate(:topic,
topic_status_updates: [Fabricate(:topic_status_update, topic_status_updates: [Fabricate(:topic_status_update,

View File

@ -76,6 +76,31 @@ RSpec.describe "Managing a topic's status update", type: :request do
expect(json['closed']).to eq(topic.closed) expect(json['closed']).to eq(topic.closed)
end end
describe 'publishing topic to category in the future' do
it 'should be able to create the topic status update' do
post "/t/#{topic.id}/status_update.json",
time: 24,
status_type: TopicStatusUpdate.types[3],
category_id: topic.category_id
expect(response).to be_success
topic_status_update = TopicStatusUpdate.last
expect(topic_status_update.topic).to eq(topic)
expect(topic_status_update.execute_at)
.to be_within(1.second).of(24.hours.from_now)
expect(topic_status_update.status_type)
.to eq(TopicStatusUpdate.types[:publish_to_category])
json = JSON.parse(response.body)
expect(json['category_id']).to eq(topic.category_id)
end
end
describe 'invalid status type' do describe 'invalid status type' do
it 'should raise the right error' do it 'should raise the right error' do
expect do expect do

View File

@ -0,0 +1,52 @@
require 'rails_helper'
RSpec.describe Jobs::PublishTopicToCategory do
let(:category) { Fabricate(:category) }
let(:another_category) { Fabricate(:category) }
let(:topic) do
Fabricate(:topic, category: category, topic_status_updates: [
Fabricate(:topic_status_update,
status_type: TopicStatusUpdate.types[:publish_to_category],
category_id: another_category.id
)
])
end
before do
SiteSetting.queue_jobs = true
end
describe 'when topic_status_update_id is invalid' do
it 'should raise the right error' do
expect { described_class.new.execute(topic_status_update_id: -1) }
.to raise_error(Discourse::InvalidParameters)
end
end
describe 'when topic has been deleted' do
it 'should not publish the topic to the new category' do
Timecop.travel(1.hour.ago) { topic }
topic.trash!
described_class.new.execute(topic_status_update_id: topic.topic_status_update.id)
topic.reload
expect(topic.category).to eq(category)
expect(topic.created_at).to be_within(1.second).of(Time.zone.now - 1.hour)
end
end
it 'should publish the topic to the new category correctly' do
Timecop.travel(1.hour.ago) { topic }
described_class.new.execute(topic_status_update_id: topic.topic_status_update.id)
topic.reload
expect(topic.category).to eq(another_category)
%w{created_at bumped_at updated_at last_posted_at}.each do |attribute|
expect(topic.public_send(attribute)).to be_within(1.second).of(Time.zone.now)
end
end
end

View File

@ -1258,7 +1258,7 @@ describe Topic do
expect(topic.reload.closed).to eq(false) expect(topic.reload.closed).to eq(false)
Timecop.freeze(3.hours.from_now) do Timecop.travel(3.hours.from_now) do
TopicStatusUpdate.ensure_consistency! TopicStatusUpdate.ensure_consistency!
expect(topic.reload.closed).to eq(true) expect(topic.reload.closed).to eq(true)
end end