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 { schedule } from "@ember/runloop";
import Component from "@ember/component";
import discourseComputed, {
observes,
on
} from "discourse-common/utils/decorators";
import discourseComputed, { observes } from "discourse-common/utils/decorators";
import {
PUBLISH_TO_CATEGORY_STATUS_TYPE,
OPEN_STATUS_TYPE,
DELETE_STATUS_TYPE,
REMINDER_TYPE,
CLOSE_STATUS_TYPE,
BUMP_TYPE
BUMP_TYPE,
DELETE_REPLIES_TYPE
} from "discourse/controllers/edit-topic-timer";
export default Component.extend({
@ -23,15 +20,18 @@ export default Component.extend({
autoBump: equal("selection", BUMP_TYPE),
publishToCategory: equal("selection", PUBLISH_TO_CATEGORY_STATUS_TYPE),
reminder: equal("selection", REMINDER_TYPE),
autoDeleteReplies: equal("selection", DELETE_REPLIES_TYPE),
showTimeOnly: or("autoOpen", "autoDelete", "reminder", "autoBump"),
@discourseComputed(
"topicTimer.updateTime",
showFutureDateInput: or(
"showTimeOnly",
"publishToCategory",
"topicTimer.category_id"
)
saveDisabled(updateTime, publishToCategory, topicTimerCategoryId) {
return isEmpty(updateTime) || (publishToCategory && !topicTimerCategoryId);
"autoClose",
"autoDeleteReplies"
),
@discourseComputed("autoDeleteReplies")
durationType(autoDeleteReplies) {
return autoDeleteReplies ? "days" : "hours";
},
@discourseComputed("topic.visible")
@ -39,25 +39,6 @@ export default Component.extend({
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")
_updateBasedOnLastPost() {
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 { equal, and, empty } from "@ember/object/computed";
import { equal, and, empty, or } from "@ember/object/computed";
import Component from "@ember/component";
import discourseComputed, { observes } from "discourse-common/utils/decorators";
import { FORMAT } from "select-kit/components/future-date-input-selector";
@ -10,10 +10,13 @@ export default Component.extend({
date: null,
time: null,
includeDateTime: true,
duration: null,
durationType: "hours",
isCustom: equal("selection", "pick_date_and_time"),
isBasedOnLastPost: equal("selection", "set_based_on_last_post"),
displayDateAndTimePicker: and("includeDateTime", "isCustom"),
displayLabel: null,
displayNumberInput: or("isBasedOnLastPost", "isBasedOnDuration"),
init() {
this._super(...arguments);
@ -21,6 +24,8 @@ export default Component.extend({
if (this.input) {
if (this.basedOnLastPost) {
this.set("selection", "set_based_on_last_post");
} else if (this.isBasedOnDuration) {
this.set("selection", null);
} else {
const datetime = moment(this.input);
this.setProperties({
@ -57,28 +62,44 @@ export default Component.extend({
this.set("basedOnLastPost", this.isBasedOnLastPost);
},
@discourseComputed("input", "isBasedOnLastPost")
duration(input, isBasedOnLastPost) {
const now = moment();
if (isBasedOnLastPost) {
return parseFloat(input);
} else {
return moment(input) - now;
}
@observes("duration")
_updateDuration() {
this.attrs.onChangeDuration &&
this.attrs.onChangeDuration(parseInt(this.duration, 0));
},
@discourseComputed("input", "isBasedOnLastPost")
executeAt(input, isBasedOnLastPost) {
if (isBasedOnLastPost) {
return moment()
.add(input, "hours")
@discourseComputed(
"input",
"duration",
"isBasedOnLastPost",
"isBasedOnDuration",
"durationType"
)
executeAt(
input,
duration,
isBasedOnLastPost,
isBasedOnDuration,
durationType
) {
if (isBasedOnLastPost || isBasedOnDuration) {
return moment(input)
.add(parseInt(duration, 0), durationType)
.format(FORMAT);
} else {
return input;
}
},
@discourseComputed("durationType")
durationLabel(durationType) {
return I18n.t(
`topic.topic_status_update.num_of_${
durationType === "hours" ? "hours" : "days"
}`
);
},
didReceiveAttrs() {
this._super(...arguments);
@ -92,7 +113,9 @@ export default Component.extend({
"date",
"time",
"willCloseImmediately",
"categoryId"
"categoryId",
"displayNumberInput",
"duration"
)
showTopicStatusInfo(
statusType,
@ -101,7 +124,9 @@ export default Component.extend({
date,
time,
willCloseImmediately,
categoryId
categoryId,
displayNumberInput,
duration
) {
if (!statusType || willCloseImmediately) return false;
@ -114,6 +139,8 @@ export default Component.extend({
return moment(`${date}${time ? " " + time : ""}`).isAfter(moment());
}
return time;
} else if (displayNumberInput) {
return duration;
} else {
return input;
}

View File

@ -3,7 +3,10 @@ import { cancel, later } from "@ember/runloop";
import Component from "@ember/component";
import { iconHTML } from "discourse-common/lib/icon-library";
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";
export default Component.extend({
@ -28,7 +31,13 @@ export default Component.extend({
},
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);
return;
}
@ -40,7 +49,7 @@ export default Component.extend({
const statusUpdateAt = moment(this.executeAt);
const duration = moment.duration(statusUpdateAt - moment());
const minutesLeft = duration.asMinutes();
if (minutesLeft > 0) {
if (minutesLeft > 0 || isDeleteRepliesType || this.basedOnLastPost) {
let rerenderDelay = 1000;
if (minutesLeft > 2160) {
rerenderDelay = 12 * 60 * 60000;
@ -51,11 +60,15 @@ export default Component.extend({
} else if (minutesLeft > 2) {
rerenderDelay = 60000;
}
let autoCloseHours = this.duration || 0;
let durationHours = parseInt(this.duration, 0) || 0;
if (isDeleteRepliesType) {
durationHours *= 24;
}
let options = {
timeLeft: duration.humanize(true),
duration: moment.duration(autoCloseHours, "hours").humanize()
duration: moment.duration(durationHours, "hours").humanize()
};
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 TopicTimer from "discourse/models/topic-timer";
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 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 REMINDER_TYPE = "reminder";
export const BUMP_TYPE = "bump";
export const DELETE_REPLIES_TYPE = "delete_replies";
export default Controller.extend(ModalFunctionality, {
loading: false,
@ -41,10 +43,16 @@ export default Controller.extend(ModalFunctionality, {
}
];
if (this.currentUser.get("staff")) {
types.push({
id: DELETE_STATUS_TYPE,
name: I18n.t("topic.auto_delete.title")
});
types.push(
{
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;
},
@ -68,7 +76,7 @@ export default Controller.extend(ModalFunctionality, {
return "true" === isPublic ? publicTopicTimer : privateTopicTimer;
},
_setTimer(time, statusType) {
_setTimer(time, duration, statusType) {
this.set("loading", true);
TopicTimer.updateStatus(
@ -76,10 +84,11 @@ export default Controller.extend(ModalFunctionality, {
time,
this.get("topicTimer.based_on_last_post"),
statusType,
this.get("topicTimer.category_id")
this.get("topicTimer.category_id"),
duration
)
.then(result => {
if (time) {
if (time || duration) {
this.send("closeModal");
setProperties(this.topicTimer, {
@ -103,17 +112,39 @@ export default Controller.extend(ModalFunctionality, {
.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: {
onChangeStatusType(value) {
this.set("topicTimer.status_type", value);
},
onChangeUpdateTime(value) {
onChangeInput(value) {
this.set("topicTimer.updateTime", value);
},
onChangeDuration(value) {
this.set("topicTimer.duration", value);
},
saveTimer() {
if (!this.get("topicTimer.updateTime")) {
if (
!this.get("topicTimer.updateTime") &&
!this.get("topicTimer.duration")
) {
this.flash(
I18n.t("topic.topic_status_update.time_frame_required"),
"alert-error"
@ -123,12 +154,13 @@ export default Controller.extend(ModalFunctionality, {
this._setTimer(
this.get("topicTimer.updateTime"),
this.get("topicTimer.duration"),
this.get("topicTimer.status_type")
);
},
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({});
TopicTimer.reopenClass({
updateStatus(topicId, time, basedOnLastPost, statusType, categoryId) {
updateStatus(
topicId,
time,
basedOnLastPost,
statusType,
categoryId,
duration
) {
let data = {
time,
status_type: statusType
@ -12,6 +19,7 @@ TopicTimer.reopenClass({
if (basedOnLastPost) data.based_on_last_post = basedOnLastPost;
if (categoryId) data.category_id = categoryId;
if (duration) data.duration = duration;
return ajax({
url: `/t/${topicId}/timer`,

View File

@ -7,46 +7,32 @@
value=selection
}}
</div>
<div>
{{#if showTimeOnly}}
{{#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>
{{/if}}
{{#if showFutureDateInput}}
<div class="control-group">
{{future-date-input
input=(readonly topicTimer.updateTime)
duration=(readonly topicTimer.duration)
label="topic.topic_status_update.when"
statusType=selection
includeWeekend=true
basedOnLastPost=topicTimer.based_on_last_post
onChangeInput=onChangeUpdateTime
}}
{{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
onChangeInput=onChangeInput
onChangeDuration=onChangeDuration
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
onChangeInput=onChangeUpdateTime
isBasedOnDuration=autoDeleteReplies
durationType=durationType
}}
{{/if}}
</div>
</div>
{{/if}}
</form>

View File

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

View File

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

View File

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

View File

@ -435,16 +435,19 @@ class TopicsController < ApplicationController
rescue
invalid_param(:status_type)
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])
guardian.ensure_can_moderate!(topic)
options = {
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!(duration: params[:duration].to_i) if params[:duration].present?
topic_status_update = topic.set_or_create_timer(
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.
# * 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_timer(status_type, time, by_user: nil, based_on_last_post: false, category_id: SiteSetting.uncategorized_category_id)
return delete_topic_timer(status_type, by_user: by_user) if time.blank?
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? && duration.blank?
public_topic_timer = !!TopicTimer.public_types[status_type]
topic_timer_options = { topic: self, public_type: public_topic_timer }
@ -1143,19 +1143,24 @@ class Topic < ActiveRecord::Base
time_now = Time.zone.now
topic_timer.based_on_last_post = !based_on_last_post.blank?
topic_timer.duration = duration
if status_type == TopicTimer.types[:publish_to_category]
topic_timer.category = Category.find_by(id: category_id)
end
if topic_timer.based_on_last_post
num_hours = time.to_f
if num_hours > 0
if duration > 0
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
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
utc = Time.find_zone("UTC")
is_float = (Float(time) rescue nil)

View File

@ -49,7 +49,8 @@ class TopicTimer < ActiveRecord::Base
publish_to_category: 3,
delete: 4,
reminder: 5,
bump: 6
bump: 6,
delete_replies: 7
)
end
@ -73,14 +74,6 @@ class TopicTimer < ActiveRecord::Base
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?
!!self.class.public_types[self.status_type]
end
@ -120,6 +113,14 @@ class TopicTimer < ActiveRecord::Base
Jobs.cancel_scheduled_job(:bump_topic, topic_timer_id: id)
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)
Jobs.enqueue_at(time, :bump_topic, topic_timer_id: id)
end

View File

@ -2139,6 +2139,7 @@ en:
title: "Topic Timer"
save: "Set Timer"
num_of_hours: "Number of hours:"
num_of_days: "Number of days:"
remove: "Remove Timer"
publish_to: "Publish To:"
when: "When:"
@ -2181,6 +2182,8 @@ en:
title: "Auto-Bump Topic"
reminder:
title: "Remind Me"
auto_delete_replies:
title: "Auto-Delete Replies"
status_update_notice:
auto_open: "This topic will automatically open %{timeLeft}."
@ -2190,6 +2193,7 @@ en:
auto_delete: "This topic will be automatically deleted %{timeLeft}."
auto_bump: "This topic will be automatically bumped %{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_immediate:
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)
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
it 'should be able to create the topic status update' do
post "/t/#{topic.id}/timer.json", params: {