FEATURE: automatically delete replies on a topic after N days. (#9209)

This commit is contained in:
Vinoth Kannan 2020-03-19 21:06:31 +05:30 committed by GitHub
parent 0cd502a558
commit aad12822b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 241 additions and 125 deletions

View File

@ -1,18 +1,15 @@
import { isEmpty } from "@ember/utils";
import { equal, or, readOnly } from "@ember/object/computed"; import { equal, or, readOnly } from "@ember/object/computed";
import { schedule } from "@ember/runloop"; import { schedule } from "@ember/runloop";
import Component from "@ember/component"; import Component from "@ember/component";
import discourseComputed, { import discourseComputed, { observes } from "discourse-common/utils/decorators";
observes,
on
} from "discourse-common/utils/decorators";
import { import {
PUBLISH_TO_CATEGORY_STATUS_TYPE, PUBLISH_TO_CATEGORY_STATUS_TYPE,
OPEN_STATUS_TYPE, OPEN_STATUS_TYPE,
DELETE_STATUS_TYPE, DELETE_STATUS_TYPE,
REMINDER_TYPE, REMINDER_TYPE,
CLOSE_STATUS_TYPE, CLOSE_STATUS_TYPE,
BUMP_TYPE BUMP_TYPE,
DELETE_REPLIES_TYPE
} from "discourse/controllers/edit-topic-timer"; } from "discourse/controllers/edit-topic-timer";
export default Component.extend({ export default Component.extend({
@ -23,15 +20,18 @@ export default Component.extend({
autoBump: equal("selection", BUMP_TYPE), autoBump: equal("selection", BUMP_TYPE),
publishToCategory: equal("selection", PUBLISH_TO_CATEGORY_STATUS_TYPE), publishToCategory: equal("selection", PUBLISH_TO_CATEGORY_STATUS_TYPE),
reminder: equal("selection", REMINDER_TYPE), reminder: equal("selection", REMINDER_TYPE),
autoDeleteReplies: equal("selection", DELETE_REPLIES_TYPE),
showTimeOnly: or("autoOpen", "autoDelete", "reminder", "autoBump"), showTimeOnly: or("autoOpen", "autoDelete", "reminder", "autoBump"),
showFutureDateInput: or(
@discourseComputed( "showTimeOnly",
"topicTimer.updateTime",
"publishToCategory", "publishToCategory",
"topicTimer.category_id" "autoClose",
) "autoDeleteReplies"
saveDisabled(updateTime, publishToCategory, topicTimerCategoryId) { ),
return isEmpty(updateTime) || (publishToCategory && !topicTimerCategoryId);
@discourseComputed("autoDeleteReplies")
durationType(autoDeleteReplies) {
return autoDeleteReplies ? "days" : "hours";
}, },
@discourseComputed("topic.visible") @discourseComputed("topic.visible")
@ -39,25 +39,6 @@ export default Component.extend({
if (visible) return this.get("topic.category_id"); if (visible) return this.get("topic.category_id");
}, },
@on("init")
@observes("topicTimer", "topicTimer.execute_at", "topicTimer.duration")
_setUpdateTime() {
let time = null;
const executeAt = this.get("topicTimer.execute_at");
if (executeAt && this.get("topicTimer.based_on_last_post")) {
time = this.get("topicTimer.duration");
} else if (executeAt) {
const closeTime = moment(executeAt);
if (closeTime > moment()) {
time = closeTime.format("YYYY-MM-DD HH:mm");
}
}
this.set("topicTimer.updateTime", time);
},
@observes("selection") @observes("selection")
_updateBasedOnLastPost() { _updateBasedOnLastPost() {
if (!this.autoClose) { if (!this.autoClose) {
@ -79,11 +60,5 @@ export default Component.extend({
); );
} }
}); });
},
actions: {
onChangeTimerType(value) {
this.set("topicTimer.status_type", value);
}
} }
}); });

View File

@ -1,5 +1,5 @@
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { equal, and, empty } from "@ember/object/computed"; import { equal, and, empty, or } from "@ember/object/computed";
import Component from "@ember/component"; import Component from "@ember/component";
import discourseComputed, { observes } from "discourse-common/utils/decorators"; import discourseComputed, { observes } from "discourse-common/utils/decorators";
import { FORMAT } from "select-kit/components/future-date-input-selector"; import { FORMAT } from "select-kit/components/future-date-input-selector";
@ -10,10 +10,13 @@ export default Component.extend({
date: null, date: null,
time: null, time: null,
includeDateTime: true, includeDateTime: true,
duration: null,
durationType: "hours",
isCustom: equal("selection", "pick_date_and_time"), isCustom: equal("selection", "pick_date_and_time"),
isBasedOnLastPost: equal("selection", "set_based_on_last_post"), isBasedOnLastPost: equal("selection", "set_based_on_last_post"),
displayDateAndTimePicker: and("includeDateTime", "isCustom"), displayDateAndTimePicker: and("includeDateTime", "isCustom"),
displayLabel: null, displayLabel: null,
displayNumberInput: or("isBasedOnLastPost", "isBasedOnDuration"),
init() { init() {
this._super(...arguments); this._super(...arguments);
@ -21,6 +24,8 @@ export default Component.extend({
if (this.input) { if (this.input) {
if (this.basedOnLastPost) { if (this.basedOnLastPost) {
this.set("selection", "set_based_on_last_post"); this.set("selection", "set_based_on_last_post");
} else if (this.isBasedOnDuration) {
this.set("selection", null);
} else { } else {
const datetime = moment(this.input); const datetime = moment(this.input);
this.setProperties({ this.setProperties({
@ -57,28 +62,44 @@ export default Component.extend({
this.set("basedOnLastPost", this.isBasedOnLastPost); this.set("basedOnLastPost", this.isBasedOnLastPost);
}, },
@discourseComputed("input", "isBasedOnLastPost") @observes("duration")
duration(input, isBasedOnLastPost) { _updateDuration() {
const now = moment(); this.attrs.onChangeDuration &&
this.attrs.onChangeDuration(parseInt(this.duration, 0));
if (isBasedOnLastPost) {
return parseFloat(input);
} else {
return moment(input) - now;
}
}, },
@discourseComputed("input", "isBasedOnLastPost") @discourseComputed(
executeAt(input, isBasedOnLastPost) { "input",
if (isBasedOnLastPost) { "duration",
return moment() "isBasedOnLastPost",
.add(input, "hours") "isBasedOnDuration",
"durationType"
)
executeAt(
input,
duration,
isBasedOnLastPost,
isBasedOnDuration,
durationType
) {
if (isBasedOnLastPost || isBasedOnDuration) {
return moment(input)
.add(parseInt(duration, 0), durationType)
.format(FORMAT); .format(FORMAT);
} else { } else {
return input; return input;
} }
}, },
@discourseComputed("durationType")
durationLabel(durationType) {
return I18n.t(
`topic.topic_status_update.num_of_${
durationType === "hours" ? "hours" : "days"
}`
);
},
didReceiveAttrs() { didReceiveAttrs() {
this._super(...arguments); this._super(...arguments);
@ -92,7 +113,9 @@ export default Component.extend({
"date", "date",
"time", "time",
"willCloseImmediately", "willCloseImmediately",
"categoryId" "categoryId",
"displayNumberInput",
"duration"
) )
showTopicStatusInfo( showTopicStatusInfo(
statusType, statusType,
@ -101,7 +124,9 @@ export default Component.extend({
date, date,
time, time,
willCloseImmediately, willCloseImmediately,
categoryId categoryId,
displayNumberInput,
duration
) { ) {
if (!statusType || willCloseImmediately) return false; if (!statusType || willCloseImmediately) return false;
@ -114,6 +139,8 @@ export default Component.extend({
return moment(`${date}${time ? " " + time : ""}`).isAfter(moment()); return moment(`${date}${time ? " " + time : ""}`).isAfter(moment());
} }
return time; return time;
} else if (displayNumberInput) {
return duration;
} else { } else {
return input; return input;
} }

View File

@ -3,7 +3,10 @@ import { cancel, later } from "@ember/runloop";
import Component from "@ember/component"; import Component from "@ember/component";
import { iconHTML } from "discourse-common/lib/icon-library"; import { iconHTML } from "discourse-common/lib/icon-library";
import Category from "discourse/models/category"; import Category from "discourse/models/category";
import { REMINDER_TYPE } from "discourse/controllers/edit-topic-timer"; import {
REMINDER_TYPE,
DELETE_REPLIES_TYPE
} from "discourse/controllers/edit-topic-timer";
import ENV from "discourse-common/config/environment"; import ENV from "discourse-common/config/environment";
export default Component.extend({ export default Component.extend({
@ -28,7 +31,13 @@ export default Component.extend({
}, },
renderTopicTimer() { renderTopicTimer() {
if (!this.executeAt || this.executeAt < moment()) { const isDeleteRepliesType = this.statusType === DELETE_REPLIES_TYPE;
if (
!isDeleteRepliesType &&
!this.basedOnLastPost &&
(!this.executeAt || this.executeAt < moment())
) {
this.set("showTopicTimer", null); this.set("showTopicTimer", null);
return; return;
} }
@ -40,7 +49,7 @@ export default Component.extend({
const statusUpdateAt = moment(this.executeAt); const statusUpdateAt = moment(this.executeAt);
const duration = moment.duration(statusUpdateAt - moment()); const duration = moment.duration(statusUpdateAt - moment());
const minutesLeft = duration.asMinutes(); const minutesLeft = duration.asMinutes();
if (minutesLeft > 0) { if (minutesLeft > 0 || isDeleteRepliesType || this.basedOnLastPost) {
let rerenderDelay = 1000; let rerenderDelay = 1000;
if (minutesLeft > 2160) { if (minutesLeft > 2160) {
rerenderDelay = 12 * 60 * 60000; rerenderDelay = 12 * 60 * 60000;
@ -51,11 +60,15 @@ export default Component.extend({
} else if (minutesLeft > 2) { } else if (minutesLeft > 2) {
rerenderDelay = 60000; rerenderDelay = 60000;
} }
let autoCloseHours = this.duration || 0; let durationHours = parseInt(this.duration, 0) || 0;
if (isDeleteRepliesType) {
durationHours *= 24;
}
let options = { let options = {
timeLeft: duration.humanize(true), timeLeft: duration.humanize(true),
duration: moment.duration(autoCloseHours, "hours").humanize() duration: moment.duration(durationHours, "hours").humanize()
}; };
const categoryId = this.categoryId; const categoryId = this.categoryId;

View File

@ -4,6 +4,7 @@ import discourseComputed from "discourse-common/utils/decorators";
import ModalFunctionality from "discourse/mixins/modal-functionality"; import ModalFunctionality from "discourse/mixins/modal-functionality";
import TopicTimer from "discourse/models/topic-timer"; import TopicTimer from "discourse/models/topic-timer";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { FORMAT } from "select-kit/components/future-date-input-selector";
export const CLOSE_STATUS_TYPE = "close"; export const CLOSE_STATUS_TYPE = "close";
export const OPEN_STATUS_TYPE = "open"; export const OPEN_STATUS_TYPE = "open";
@ -11,6 +12,7 @@ export const PUBLISH_TO_CATEGORY_STATUS_TYPE = "publish_to_category";
export const DELETE_STATUS_TYPE = "delete"; export const DELETE_STATUS_TYPE = "delete";
export const REMINDER_TYPE = "reminder"; export const REMINDER_TYPE = "reminder";
export const BUMP_TYPE = "bump"; export const BUMP_TYPE = "bump";
export const DELETE_REPLIES_TYPE = "delete_replies";
export default Controller.extend(ModalFunctionality, { export default Controller.extend(ModalFunctionality, {
loading: false, loading: false,
@ -41,10 +43,16 @@ export default Controller.extend(ModalFunctionality, {
} }
]; ];
if (this.currentUser.get("staff")) { if (this.currentUser.get("staff")) {
types.push({ types.push(
id: DELETE_STATUS_TYPE, {
name: I18n.t("topic.auto_delete.title") id: DELETE_STATUS_TYPE,
}); name: I18n.t("topic.auto_delete.title")
},
{
id: DELETE_REPLIES_TYPE,
name: I18n.t("topic.auto_delete_replies.title")
}
);
} }
return types; return types;
}, },
@ -68,7 +76,7 @@ export default Controller.extend(ModalFunctionality, {
return "true" === isPublic ? publicTopicTimer : privateTopicTimer; return "true" === isPublic ? publicTopicTimer : privateTopicTimer;
}, },
_setTimer(time, statusType) { _setTimer(time, duration, statusType) {
this.set("loading", true); this.set("loading", true);
TopicTimer.updateStatus( TopicTimer.updateStatus(
@ -76,10 +84,11 @@ export default Controller.extend(ModalFunctionality, {
time, time,
this.get("topicTimer.based_on_last_post"), this.get("topicTimer.based_on_last_post"),
statusType, statusType,
this.get("topicTimer.category_id") this.get("topicTimer.category_id"),
duration
) )
.then(result => { .then(result => {
if (time) { if (time || duration) {
this.send("closeModal"); this.send("closeModal");
setProperties(this.topicTimer, { setProperties(this.topicTimer, {
@ -103,17 +112,39 @@ export default Controller.extend(ModalFunctionality, {
.finally(() => this.set("loading", false)); .finally(() => this.set("loading", false));
}, },
onShow() {
let time = null;
const executeAt = this.get("topicTimer.execute_at");
if (executeAt) {
const closeTime = moment(executeAt);
if (closeTime > moment()) {
time = closeTime.format(FORMAT);
}
}
this.send("onChangeInput", time);
},
actions: { actions: {
onChangeStatusType(value) { onChangeStatusType(value) {
this.set("topicTimer.status_type", value); this.set("topicTimer.status_type", value);
}, },
onChangeUpdateTime(value) { onChangeInput(value) {
this.set("topicTimer.updateTime", value); this.set("topicTimer.updateTime", value);
}, },
onChangeDuration(value) {
this.set("topicTimer.duration", value);
},
saveTimer() { saveTimer() {
if (!this.get("topicTimer.updateTime")) { if (
!this.get("topicTimer.updateTime") &&
!this.get("topicTimer.duration")
) {
this.flash( this.flash(
I18n.t("topic.topic_status_update.time_frame_required"), I18n.t("topic.topic_status_update.time_frame_required"),
"alert-error" "alert-error"
@ -123,12 +154,13 @@ export default Controller.extend(ModalFunctionality, {
this._setTimer( this._setTimer(
this.get("topicTimer.updateTime"), this.get("topicTimer.updateTime"),
this.get("topicTimer.duration"),
this.get("topicTimer.status_type") this.get("topicTimer.status_type")
); );
}, },
removeTimer() { removeTimer() {
this._setTimer(null, this.get("topicTimer.status_type")); this._setTimer(null, null, this.get("topicTimer.status_type"));
} }
} }
}); });

View File

@ -4,7 +4,14 @@ import RestModel from "discourse/models/rest";
const TopicTimer = RestModel.extend({}); const TopicTimer = RestModel.extend({});
TopicTimer.reopenClass({ TopicTimer.reopenClass({
updateStatus(topicId, time, basedOnLastPost, statusType, categoryId) { updateStatus(
topicId,
time,
basedOnLastPost,
statusType,
categoryId,
duration
) {
let data = { let data = {
time, time,
status_type: statusType status_type: statusType
@ -12,6 +19,7 @@ TopicTimer.reopenClass({
if (basedOnLastPost) data.based_on_last_post = basedOnLastPost; if (basedOnLastPost) data.based_on_last_post = basedOnLastPost;
if (categoryId) data.category_id = categoryId; if (categoryId) data.category_id = categoryId;
if (duration) data.duration = duration;
return ajax({ return ajax({
url: `/t/${topicId}/timer`, url: `/t/${topicId}/timer`,

View File

@ -7,46 +7,32 @@
value=selection value=selection
}} }}
</div> </div>
{{#if publishToCategory}}
<div> <div class="control-group">
{{#if showTimeOnly}} <label>{{i18n 'topic.topic_status_update.publish_to'}}</label>
{{category-chooser
value=topicTimer.category_id
excludeCategoryId=excludeCategoryId
onChange=(action (mut topicTimer.category_id))
}}
</div>
{{/if}}
{{#if showFutureDateInput}}
<div class="control-group">
{{future-date-input {{future-date-input
input=(readonly topicTimer.updateTime) input=(readonly topicTimer.updateTime)
duration=(readonly topicTimer.duration)
label="topic.topic_status_update.when" label="topic.topic_status_update.when"
statusType=selection statusType=selection
includeWeekend=true includeWeekend=true
basedOnLastPost=topicTimer.based_on_last_post basedOnLastPost=topicTimer.based_on_last_post
onChangeInput=onChangeUpdateTime onChangeInput=onChangeInput
}} onChangeDuration=onChangeDuration
{{else if publishToCategory}}
<div class="control-group">
<label>{{i18n 'topic.topic_status_update.publish_to'}}</label>
{{category-chooser
value=topicTimer.category_id
excludeCategoryId=excludeCategoryId
onChange=(action (mut topicTimer.category_id))
}}
</div>
{{future-date-input
input=(readonly topicTimer.updateTime)
label="topic.topic_status_update.when"
statusType=selection
includeWeekend=true
categoryId=topicTimer.category_id categoryId=topicTimer.category_id
basedOnLastPost=topicTimer.based_on_last_post
onChangeInput=onChangeUpdateTime
}}
{{else if autoClose}}
{{future-date-input
input=topicTimer.updateTime
label="topic.topic_status_update.when"
statusType=selection
includeWeekend=true
basedOnLastPost=topicTimer.based_on_last_post
lastPostedAt=model.last_posted_at lastPostedAt=model.last_posted_at
onChangeInput=onChangeUpdateTime isBasedOnDuration=autoDeleteReplies
durationType=durationType
}} }}
{{/if}} </div>
</div> {{/if}}
</form> </form>

View File

@ -1,4 +1,5 @@
<div class="future-date-input"> <div class="future-date-input">
{{#unless isBasedOnDuration}}
<div class="control-group"> <div class="control-group">
<label>{{displayLabel}}</label> <label>{{displayLabel}}</label>
{{future-date-input-selector {{future-date-input-selector
@ -16,6 +17,7 @@
onChange=(action (mut selection)) onChange=(action (mut selection))
}} }}
</div> </div>
{{/unless}}
{{#if displayDateAndTimePicker}} {{#if displayDateAndTimePicker}}
<div class="control-group"> <div class="control-group">
@ -33,11 +35,11 @@
</div> </div>
{{/if}} {{/if}}
{{#if isBasedOnLastPost}} {{#if displayNumberInput}}
<div class="control-group"> <div class="control-group">
<label> <label>
{{i18n 'topic.topic_status_update.num_of_hours'}} {{durationLabel}}
{{text-field value=input type="number"}} {{text-field value=duration type="number"}}
</label> </label>
</div> </div>

View File

@ -17,7 +17,8 @@
timerTypes=selections timerTypes=selections
updateTime=updateTime updateTime=updateTime
onChangeStatusType=(action "onChangeStatusType") onChangeStatusType=(action "onChangeStatusType")
onChangeUpdateTime=(action "onChangeUpdateTime") onChangeInput=(action "onChangeInput")
onChangeDuration=(action "onChangeDuration")
}} }}
{{/d-modal-body}} {{/d-modal-body}}

View File

@ -203,7 +203,7 @@ export default ComboBoxComponent.extend(DatetimeMixin, {
return "future-date-input-selector/future-date-input-selector-row"; return "future-date-input-selector/future-date-input-selector-row";
}, },
content: computed(function() { content: computed("statusType", function() {
const now = moment(); const now = moment();
const opts = { const opts = {
now, now,

View File

@ -435,16 +435,19 @@ class TopicsController < ApplicationController
rescue rescue
invalid_param(:status_type) invalid_param(:status_type)
end end
based_on_last_post = params[:based_on_last_post]
params.require(:duration) if based_on_last_post || TopicTimer.types[:delete_replies] == status_type
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)
options = { options = {
by_user: current_user, by_user: current_user,
based_on_last_post: params[:based_on_last_post] based_on_last_post: based_on_last_post
} }
options.merge!(category_id: params[:category_id]) if !params[:category_id].blank? options.merge!(category_id: params[:category_id]) if !params[:category_id].blank?
options.merge!(duration: params[:duration].to_i) if params[:duration].present?
topic_status_update = topic.set_or_create_timer( topic_status_update = topic.set_or_create_timer(
status_type, status_type,

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
module Jobs
class DeleteReplies < ::Jobs::Base
def execute(args)
topic_timer = TopicTimer.find_by(id: args[:topic_timer_id] || args[:topic_status_update_id])
topic = topic_timer&.topic
if topic_timer.blank? || topic.blank? || topic_timer.execute_at > Time.zone.now
return
end
unless Guardian.new(topic_timer.user).is_staff?
topic_timer.trash!(Discourse.system_user)
return
end
replies = topic.posts.where("posts.post_number > 1")
replies.where('posts.created_at < ?', topic_timer.duration.days.ago).each do |post|
PostDestroyer.new(topic_timer.user, post, context: I18n.t("topic_statuses.auto_deleted_by_timer")).destroy
end
topic_timer.execute_at = (replies.minimum(:created_at) || Time.zone.now) + topic_timer.duration.days.ago
topic_timer.save
end
end
end

View File

@ -1132,8 +1132,8 @@ class Topic < ActiveRecord::Base
# * by_user: User who is setting the topic's status update. # * by_user: User who is setting the topic's status update.
# * based_on_last_post: True if time should be based on timestamp of the last post. # * 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. # * category_id: Category that the update will apply to.
def set_or_create_timer(status_type, time, by_user: nil, based_on_last_post: false, category_id: SiteSetting.uncategorized_category_id) def set_or_create_timer(status_type, time, by_user: nil, based_on_last_post: false, category_id: SiteSetting.uncategorized_category_id, duration: nil)
return delete_topic_timer(status_type, by_user: by_user) if time.blank? return delete_topic_timer(status_type, by_user: by_user) if time.blank? && duration.blank?
public_topic_timer = !!TopicTimer.public_types[status_type] public_topic_timer = !!TopicTimer.public_types[status_type]
topic_timer_options = { topic: self, public_type: public_topic_timer } topic_timer_options = { topic: self, public_type: public_topic_timer }
@ -1143,19 +1143,24 @@ class Topic < ActiveRecord::Base
time_now = Time.zone.now time_now = Time.zone.now
topic_timer.based_on_last_post = !based_on_last_post.blank? topic_timer.based_on_last_post = !based_on_last_post.blank?
topic_timer.duration = duration
if status_type == TopicTimer.types[:publish_to_category] if status_type == TopicTimer.types[:publish_to_category]
topic_timer.category = Category.find_by(id: category_id) topic_timer.category = Category.find_by(id: category_id)
end end
if topic_timer.based_on_last_post if topic_timer.based_on_last_post
num_hours = time.to_f if duration > 0
if num_hours > 0
last_post_created_at = self.ordered_posts.last.present? ? self.ordered_posts.last.created_at : time_now last_post_created_at = self.ordered_posts.last.present? ? self.ordered_posts.last.created_at : time_now
topic_timer.execute_at = last_post_created_at + num_hours.hours topic_timer.execute_at = last_post_created_at + duration.hours
topic_timer.created_at = last_post_created_at topic_timer.created_at = last_post_created_at
end end
elsif topic_timer.status_type == TopicTimer.types[:delete_replies]
if duration > 0
first_reply_created_at = (self.ordered_posts.where("post_number > 1").minimum(:created_at) || time_now)
topic_timer.execute_at = first_reply_created_at + duration.days
topic_timer.created_at = first_reply_created_at
end
else else
utc = Time.find_zone("UTC") utc = Time.find_zone("UTC")
is_float = (Float(time) rescue nil) is_float = (Float(time) rescue nil)

View File

@ -49,7 +49,8 @@ class TopicTimer < ActiveRecord::Base
publish_to_category: 3, publish_to_category: 3,
delete: 4, delete: 4,
reminder: 5, reminder: 5,
bump: 6 bump: 6,
delete_replies: 7
) )
end end
@ -73,14 +74,6 @@ class TopicTimer < ActiveRecord::Base
end end
end end
def duration
if (self.execute_at && self.created_at)
((self.execute_at - self.created_at) / 1.hour).round(2)
else
0
end
end
def public_type? def public_type?
!!self.class.public_types[self.status_type] !!self.class.public_types[self.status_type]
end end
@ -120,6 +113,14 @@ class TopicTimer < ActiveRecord::Base
Jobs.cancel_scheduled_job(:bump_topic, topic_timer_id: id) Jobs.cancel_scheduled_job(:bump_topic, topic_timer_id: id)
end end
def cancel_auto_delete_replies_job
Jobs.cancel_scheduled_job(:delete_replies, topic_timer_id: id)
end
def schedule_auto_delete_replies_job(time)
Jobs.enqueue_at(time, :delete_replies, topic_timer_id: id)
end
def schedule_auto_bump_job(time) def schedule_auto_bump_job(time)
Jobs.enqueue_at(time, :bump_topic, topic_timer_id: id) Jobs.enqueue_at(time, :bump_topic, topic_timer_id: id)
end end

View File

@ -2139,6 +2139,7 @@ en:
title: "Topic Timer" title: "Topic Timer"
save: "Set Timer" save: "Set Timer"
num_of_hours: "Number of hours:" num_of_hours: "Number of hours:"
num_of_days: "Number of days:"
remove: "Remove Timer" remove: "Remove Timer"
publish_to: "Publish To:" publish_to: "Publish To:"
when: "When:" when: "When:"
@ -2181,6 +2182,8 @@ en:
title: "Auto-Bump Topic" title: "Auto-Bump Topic"
reminder: reminder:
title: "Remind Me" title: "Remind Me"
auto_delete_replies:
title: "Auto-Delete Replies"
status_update_notice: status_update_notice:
auto_open: "This topic will automatically open %{timeLeft}." auto_open: "This topic will automatically open %{timeLeft}."
@ -2190,6 +2193,7 @@ en:
auto_delete: "This topic will be automatically deleted %{timeLeft}." auto_delete: "This topic will be automatically deleted %{timeLeft}."
auto_bump: "This topic will be automatically bumped %{timeLeft}." auto_bump: "This topic will be automatically bumped %{timeLeft}."
auto_reminder: "You will be reminded about this topic %{timeLeft}." auto_reminder: "You will be reminded about this topic %{timeLeft}."
auto_delete_replies: "Replies on this topic are automatically deleted after %{duration}."
auto_close_title: "Auto-Close Settings" auto_close_title: "Auto-Close Settings"
auto_close_immediate: auto_close_immediate:
one: "The last post in the topic is already %{count} hour old, so the topic will be closed immediately." one: "The last post in the topic is already %{count} hour old, so the topic will be closed immediately."

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddDurationToTopicTimers < ActiveRecord::Migration[6.0]
def change
add_column :topic_timers, :duration, :integer, null: true
end
end

View File

@ -2644,6 +2644,28 @@ RSpec.describe TopicsController do
expect(json['closed']).to eq(topic.closed) expect(json['closed']).to eq(topic.closed)
end end
it 'should be able to create a topic status update with duration' do
post "/t/#{topic.id}/timer.json", params: {
duration: 5,
status_type: TopicTimer.types[7]
}
expect(response.status).to eq(200)
topic_status_update = TopicTimer.last
expect(topic_status_update.topic).to eq(topic)
expect(topic_status_update.execute_at).to eq_time(5.days.from_now)
expect(topic_status_update.duration).to eq(5)
json = JSON.parse(response.body)
expect(DateTime.parse(json['execute_at']))
.to eq_time(DateTime.parse(topic_status_update.execute_at.to_s))
expect(json['duration']).to eq(topic_status_update.duration)
end
describe 'publishing topic to category in the future' do describe 'publishing topic to category in the future' do
it 'should be able to create the topic status update' do it 'should be able to create the topic status update' do
post "/t/#{topic.id}/timer.json", params: { post "/t/#{topic.id}/timer.json", params: {